From bca59c4caad3cc101362993d06340505f47c8d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pr=C3=A9vost?= Date: Thu, 5 Apr 2018 16:47:36 -0400 Subject: [PATCH] Initial commit :boom: --- .credo.exs | 65 + .formatter.exs | 4 + .gitignore | 26 + .hanzo.yml | 5 + .travis.yml | 43 + Aptfile | 1 + CODE_OF_CONDUCT.md | 38 + Gemfile | 5 + Gemfile.lock | 15 + LICENSE | 9 + Procfile | 1 + README.md | 124 + compile | 2 + config/config.exs | 67 + config/dev.exs | 22 + config/mailer.exs | 18 + config/prod.exs | 6 + config/test.exs | 26 + elixir_buildpack.config | 11 + lib/accent.ex | 36 + lib/accent/auth/canada_implementations.ex | 23 + lib/accent/auth/role_abilities.ex | 99 + lib/accent/auth/user_auth_fetcher.ex | 49 + .../auth/user_remote/adapter/fetcher.ex | 5 + lib/accent/auth/user_remote/adapter/user.ex | 5 + lib/accent/auth/user_remote/adapters/dummy.ex | 17 + .../auth/user_remote/adapters/google.ex | 39 + lib/accent/auth/user_remote/authenticator.ex | 27 + .../user_remote/collaborator_normalizer.ex | 28 + lib/accent/auth/user_remote/fetcher.ex | 17 + lib/accent/auth/user_remote/persister.ex | 54 + lib/accent/auth/user_remote/token_giver.ex | 25 + lib/accent/badge_generator.ex | 67 + .../collaborators/collaborator_creator.ex | 21 + .../collaborators/collaborator_updater.ex | 9 + lib/accent/endpoint.ex | 45 + .../integrations/integration_manager.ex | 40 + lib/accent/mailer.ex | 3 + lib/accent/operations/operation_batcher.ex | 73 + lib/accent/projects/project_creator.ex | 53 + lib/accent/projects/project_deleter.ex | 11 + lib/accent/projects/project_updater.ex | 25 + lib/accent/repo.ex | 4 + lib/accent/revisions/revision_deleter.ex | 17 + .../revisions/revision_master_promoter.ex | 30 + lib/accent/schemas/access_token.ex | 12 + lib/accent/schemas/auth_provider.ex | 12 + lib/accent/schemas/collaborator.ex | 41 + lib/accent/schemas/comment.ex | 30 + lib/accent/schemas/document.ex | 41 + lib/accent/schemas/document_format.ex | 45 + lib/accent/schemas/integration.ex | 14 + lib/accent/schemas/integration_data.ex | 7 + lib/accent/schemas/language.ex | 17 + lib/accent/schemas/operation.ex | 79 + lib/accent/schemas/previous_translation.ex | 38 + lib/accent/schemas/project.ex | 29 + lib/accent/schemas/revision.ex | 39 + lib/accent/schemas/role.ex | 33 + lib/accent/schemas/schema.ex | 14 + lib/accent/schemas/translation.ex | 49 + .../translation_comments_subscription.ex | 21 + lib/accent/schemas/user.ex | 30 + lib/accent/schemas/version.ex | 25 + lib/accent/scopes/comment.ex | 33 + lib/accent/scopes/document.ex | 25 + lib/accent/scopes/language.ex | 26 + lib/accent/scopes/operation.ex | 79 + lib/accent/scopes/project.ex | 26 + lib/accent/scopes/revision.ex | 52 + lib/accent/scopes/translation.ex | 236 + lib/accent/scopes/version.ex | 33 + .../translations/translations_counter.ex | 65 + .../translations/translations_renderer.ex | 58 + lib/accessible.ex | 53 + lib/graphql/datetime_scalar.ex | 56 + lib/graphql/helpers/authorization.ex | 151 + lib/graphql/helpers/fields.ex | 15 + lib/graphql/mutations/collaborator.ex | 30 + lib/graphql/mutations/comment.ex | 27 + lib/graphql/mutations/document.ex | 13 + lib/graphql/mutations/integration.ex | 38 + lib/graphql/mutations/operation.ex | 13 + lib/graphql/mutations/project.ex | 30 + lib/graphql/mutations/revision.ex | 40 + lib/graphql/mutations/translation.ex | 29 + lib/graphql/mutations/version.ex | 23 + lib/graphql/paginated.ex | 43 + lib/graphql/plugins/authorization.ex | 12 + lib/graphql/resolvers/access_token.ex | 26 + lib/graphql/resolvers/activity.ex | 52 + lib/graphql/resolvers/collaborator.ex | 64 + lib/graphql/resolvers/comment.ex | 66 + lib/graphql/resolvers/document.ex | 72 + lib/graphql/resolvers/document_format.ex | 9 + lib/graphql/resolvers/integration.ex | 40 + lib/graphql/resolvers/language.ex | 21 + lib/graphql/resolvers/operation.ex | 24 + lib/graphql/resolvers/permission.ex | 16 + lib/graphql/resolvers/project.ex | 80 + lib/graphql/resolvers/revision.ex | 137 + lib/graphql/resolvers/role.ex | 9 + lib/graphql/resolvers/translation.ex | 161 + .../translation_comment_subscription.ex | 42 + lib/graphql/resolvers/version.ex | 59 + lib/graphql/resolvers/viewer.ex | 11 + lib/graphql/schema.ex | 98 + lib/graphql/types/activity.ex | 98 + lib/graphql/types/collaborator.ex | 44 + lib/graphql/types/comment.ex | 25 + lib/graphql/types/document.ex | 17 + lib/graphql/types/document_format.ex | 21 + lib/graphql/types/integration.ex | 24 + lib/graphql/types/language.ex | 14 + lib/graphql/types/mutation_result.ex | 53 + lib/graphql/types/pagination.ex | 11 + lib/graphql/types/project.ex | 91 + lib/graphql/types/revision.ex | 31 + lib/graphql/types/role.ex | 15 + lib/graphql/types/translation.ex | 77 + lib/graphql/types/user.ex | 18 + lib/graphql/types/version.ex | 19 + lib/graphql/types/viewer.ex | 22 + lib/hook/broadcaster.ex | 15 + lib/hook/consumers/email.ex | 50 + lib/hook/consumers/slack.ex | 64 + lib/hook/consumers/websocket.ex | 69 + lib/hook/context.ex | 7 + lib/hook/event_consumer.ex | 14 + lib/hook/event_producer.ex | 33 + lib/hook/producers/email.ex | 3 + lib/hook/producers/slack.ex | 3 + lib/hook/producers/websocket.ex | 3 + lib/langue/entry.ex | 3 + lib/langue/formatter/android/parser.ex | 107 + lib/langue/formatter/android/serializer.ex | 81 + lib/langue/formatter/es6_module/parser.ex | 28 + lib/langue/formatter/es6_module/serializer.ex | 12 + lib/langue/formatter/gettext/parser.ex | 64 + lib/langue/formatter/gettext/serializer.ex | 73 + .../formatter/java_properties/parser.ex | 15 + .../formatter/java_properties/serializer.ex | 13 + .../formatter/java_properties_xml/parser.ex | 19 + .../java_properties_xml/serializer.ex | 24 + lib/langue/formatter/json/parser.ex | 18 + lib/langue/formatter/json/serializer.ex | 39 + lib/langue/formatter/parser_result.ex | 8 + lib/langue/formatter/rails/parser.ex | 17 + lib/langue/formatter/rails/serializer.ex | 20 + lib/langue/formatter/serializer_result.ex | 8 + lib/langue/formatter/simple_json/parser.ex | 7 + .../formatter/simple_json/serializer.ex | 18 + lib/langue/formatter/strings/parser.ex | 15 + lib/langue/formatter/strings/serializer.ex | 14 + lib/langue/utils/line_by_line_helper.ex | 40 + lib/langue/utils/nested_parser_helper.ex | 81 + lib/langue/utils/nested_serializer_helper.ex | 43 + lib/langue/utils/parser.ex | 3 + lib/langue/utils/serializer.ex | 3 + lib/movement/builder.ex | 3 + lib/movement/builders/document_delete.ex | 43 + lib/movement/builders/new_slave.ex | 55 + lib/movement/builders/new_version.ex | 38 + lib/movement/builders/project_sync.ex | 59 + lib/movement/builders/revision_correct_all.ex | 37 + lib/movement/builders/revision_merge.ex | 27 + lib/movement/builders/revision_sync.ex | 27 + .../builders/revision_uncorrect_all.ex | 37 + lib/movement/builders/rollback.ex | 40 + lib/movement/builders/slave_conflict_sync.ex | 59 + .../builders/translation_correct_conflict.ex | 13 + .../translation_uncorrect_conflict.ex | 13 + lib/movement/builders/translation_update.ex | 27 + lib/movement/comparer.ex | 3 + lib/movement/comparers/merge_force.ex | 54 + lib/movement/comparers/merge_passive.ex | 58 + lib/movement/comparers/merge_smart.ex | 63 + lib/movement/comparers/sync.ex | 45 + lib/movement/context.ex | 11 + lib/movement/ecto_migration_helper.ex | 21 + lib/movement/entries_commit_processor.ex | 79 + lib/movement/mappers/operation.ex | 47 + lib/movement/mappers/operations_stats.ex | 14 + lib/movement/migration.ex | 5 + lib/movement/migration/conflict.ex | 55 + lib/movement/migration/rollback.ex | 27 + lib/movement/migration/translation.ex | 93 + lib/movement/migrator.ex | 74 + lib/movement/migrator_macros.ex | 37 + lib/movement/operation.ex | 19 + lib/movement/persister.ex | 3 + lib/movement/persisters/base.ex | 104 + lib/movement/persisters/document_delete.ex | 16 + lib/movement/persisters/new_slave.ex | 36 + lib/movement/persisters/new_version.ex | 36 + lib/movement/persisters/project_sync.ex | 50 + .../persisters/revision_correct_all.ex | 16 + lib/movement/persisters/revision_merge.ex | 16 + .../persisters/revision_uncorrect_all.ex | 16 + lib/movement/persisters/rollback.ex | 25 + lib/movement/suggested_translation.ex | 4 + lib/movement/translation_comparer.ex | 118 + lib/utils/pretty_float.ex | 20 + lib/utils/secure_random.ex | 42 + lib/web/channels/project_channel.ex | 15 + lib/web/channels/user_socket.ex | 19 + .../controllers/authentication_controller.ex | 43 + lib/web/controllers/badge_controller.ex | 33 + lib/web/controllers/error_controller.ex | 15 + lib/web/controllers/export_controller.ex | 142 + lib/web/controllers/merge_controller.ex | 107 + lib/web/controllers/peek_controller.ex | 142 + lib/web/controllers/sync_controller.ex | 77 + lib/web/controllers/webapp_controller.ex | 11 + lib/web/emails/create_comment_email.ex | 34 + lib/web/emails/project_invite_email.ex | 31 + lib/web/plugs/assign_current_user.ex | 21 + lib/web/plugs/bot_params_injector.ex | 33 + .../plugs/ensure_unlocked_file_operations.ex | 13 + lib/web/plugs/graphql_context.ex | 14 + lib/web/plugs/movement_context_parser.ex | 120 + .../revision_id_from_project_language.ex | 16 + lib/web/plugs/sentry_user_context.ex | 18 + lib/web/router.ex | 58 + lib/web/templates/badge/reviewed.svg.eex | 1 + .../templates/email/create_comment.html.eex | 4 + .../templates/email/create_comment.text.eex | 6 + .../templates/email/project_invite.html.eex | 5 + .../templates/email/project_invite.text.eex | 5 + lib/web/templates/email_layout/index.html.eex | 21 + lib/web/templates/email_layout/index.text.eex | 9 + lib/web/views/badge_view.ex | 3 + lib/web/views/email_layout_view.ex | 13 + lib/web/views/email_view.ex | 6 + lib/web/views/email_view_config_helper.ex | 9 + lib/web/views/email_view_style_helper.ex | 34 + lib/web/views/error_view.ex | 51 + lib/web/views/peek_view.ex | 39 + logo.svg | 1 + mix.exs | 100 + mix.lock | 65 + phoenix_static_buildpack.config | 2 + priv/repo/languages.json | 3132 ++++ .../20150929150000_create_languages.exs | 13 + .../20150929150001_create_projects.exs | 13 + .../20150929150002_create_revisions.exs | 19 + .../20150929160926_create_translations.exs | 23 + .../20151019020854_create_comments.exs | 14 + .../20151019020855_create_operations.exs | 27 + .../20151022000813_create_users.exs | 43 + ...151026012626_add_user_id_to_operations.exs | 9 + ..._constraint_revisions_language_project.exs | 7 + ...20151221054134_add_user_id_to_comments.exs | 9 + ...538_add_comments_count_to_translations.exs | 9 + .../20160117013744_create_collaborators.exs | 20 + ..._path_and_file_comment_to_translations.exs | 10 + ...dd_path_and_file_comment_to_operations.exs | 10 + ...e_index_to_operations_and_translations.exs | 13 + ...225005143_add_rollbacked_to_operations.exs | 9 + .../20160301185757_add_bot_field_to_user.exs | 9 + ...15122640_add_last_synced_at_to_project.exs | 9 + .../20160317201856_create_documents.exs | 29 + ..._update_translations_text_to_text_type.exs | 23 + ...130613_add_value_type_for_translations.exs | 13 + ...change_length_of_uid_for_auth_provider.exs | 9 + ...20160605190558_add_stats_to_operations.exs | 9 + ..._add_lock_file_operations_for_projects.exs | 9 + ...ate_translation_comments_subscriptions.exs | 15 + .../20170415135352_create_integrations.exs | 18 + ...m_operation_to_rollbacked_operation_id.exs | 7 + ...op_file_comment_and_header_to_document.exs | 10 + .../20180202190429_create_versions.exs | 26 + ..._source_translation_id_to_translations.exs | 9 + ...53309_add_locales_columns_on_languages.exs | 16 + ...0180219005702_add_picture_url_on_users.exs | 9 + .../20180322165819_drop_auth_application.exs | 11 + priv/repo/seeds.exs | 12 + priv/scripts/ci-check.sh | 59 + priv/static/images/accent.png | Bin 0 -> 12106 bytes .../auth/user_remote/adapters/google_test.exs | 55 + test/auth/user_remote/authenticator_test.exs | 51 + test/auth/user_remote/fetcher_test.exs | 38 + test/auth/user_remote/persister_test.exs | 38 + test/auth/user_remote/token_giver_test.exs | 32 + test/badge_generator_test.exs | 119 + test/emails/create_comment_email_test.exs | 32 + test/emails/project_invite_email_test.exs | 29 + test/graphql/helpers/authorization_test.exs | 443 + test/graphql/helpers/fields_test.exs | 4 + test/graphql/resolvers/access_token_test.exs | 22 + test/graphql/resolvers/activity_test.exs | 137 + test/graphql/resolvers/collaborator_test.exs | 61 + test/graphql/resolvers/comment_test.exs | 63 + test/graphql/resolvers/document_test.exs | 67 + test/graphql/resolvers/integration_test.exs | 55 + test/graphql/resolvers/language_test.exs | 23 + test/graphql/resolvers/permission_test.exs | 44 + test/graphql/resolvers/project_test.exs | 164 + test/graphql/resolvers/revision_test.exs | 94 + .../translation_comment_subscription_test.exs | 55 + test/graphql/resolvers/translation_test.exs | 179 + test/graphql/resolvers/version_test.exs | 63 + test/graphql/resolvers/viewer_test.exs | 34 + test/hook/consumers/slack_test.exs | 61 + test/hook/producers/slack_test.exs | 32 + test/langue/android/expectation_test.exs | 160 + test/langue/android/formatter_test.exs | 39 + test/langue/es6_module/expectation_test.exs | 29 + test/langue/es6_module/formatter_test.exs | 20 + test/langue/gettext/expectation_test.exs | 94 + test/langue/gettext/formatter_test.exs | 31 + .../java_properties/expectation_test.exs | 25 + .../langue/java_properties/formatter_test.exs | 22 + .../java_properties_xml/expectation_test.exs | 30 + .../java_properties_xml/formatter_test.exs | 22 + test/langue/json/expectation_test.exs | 207 + test/langue/json/formatter_test.exs | 28 + test/langue/rails/expectation_test.exs | 93 + test/langue/rails/formatter_test.exs | 25 + test/langue/simple_json/expectation_test.exs | 60 + test/langue/simple_json/formatter_test.exs | 33 + test/langue/strings/expectation_test.exs | 92 + test/langue/strings/formatter_test.exs | 25 + test/movement/builders/new_slave_test.exs | 105 + test/movement/builders/new_version_test.exs | 112 + test/movement/builders/project_sync_test.exs | 76 + .../builders/revision_correct_all_test.exs | 66 + .../movement/builders/revision_merge_test.exs | 49 + test/movement/builders/revision_sync_test.exs | 104 + .../builders/revision_uncorrect_all_test.exs | 66 + test/movement/builders/rollback_test.exs | 97 + .../builders/slave_conflict_sync_test.exs | 62 + .../translation_correct_conflict_test.exs | 32 + .../translation_uncorrect_conflict_test.exs | 30 + .../builders/translation_update_test.exs | 104 + test/movement/comparers/merge_force_test.exs | 4 + .../movement/comparers/merge_passive_test.exs | 4 + test/movement/comparers/merge_smart_test.exs | 4 + test/movement/comparers/sync_test.exs | 4 + test/movement/down_test.exs | 135 + test/movement/persisters/base_test.exs | 193 + test/movement/persisters/new_slave_test.exs | 48 + .../movement/persisters/project_sync_test.exs | 142 + test/movement/persisters/rollback_test.exs | 215 + test/movement/translation_comparer_test.exs | 4 + test/movement/up_test.exs | 184 + test/plugs/assign_current_user_test.exs | 46 + test/plugs/bot_params_injector_test.exs | 65 + .../ensure_unlocked_file_operations_test.exs | 30 + test/plugs/graphql_context_test.exs | 15 + test/plugs/movement_context_parser_test.exs | 190 + test/pretty_float_test.exs | 4 + test/schemas/document_format_test.exs | 4 + test/schemas/previous_translation_test.exs | 5 + test/schemas/role_test.exs | 4 + test/schemas/user_test.exs | 4 + test/scopes/comment_test.exs | 4 + test/scopes/document_test.exs | 4 + test/scopes/language_test.exs | 4 + test/scopes/operation_test.exs | 4 + test/scopes/project_test.exs | 4 + test/scopes/revision_test.exs | 4 + test/scopes/translation_test.exs | 4 + test/scopes/version_test.exs | 4 + test/services/collaborator_creator_test.exs | 55 + .../services/collaborator_normalizer_test.exs | 66 + test/services/collaborator_updater_test.exs | 19 + test/services/operation_batcher_test.exs | 202 + test/services/project_creator_test.exs | 45 + test/services/project_deleter_test.exs | 17 + test/services/revision_deleter_test.exs | 45 + .../revision_master_promoter_test.exs | 34 + test/services/translations_renderer_test.exs | 119 + test/support/conn_case.ex | 43 + test/support/formatter/gettext/simple.po | 17 + test/support/formatter/json/simple.json | 5 + test/support/invalid_file.json | 5 + test/support/mocks.ex | 1 + test/support/repo_case.ex | 13 + test/test_helper.exs | 34 + .../authentication_controller_test.exs | 31 + .../web/controllers/badge_controller_test.exs | 64 + .../web/controllers/error_controller_test.exs | 19 + .../controllers/export_controller_test.exs | 196 + .../web/controllers/merge_controller_test.exs | 131 + test/web/controllers/peek_controller_test.exs | 175 + test/web/controllers/sync_controller_test.exs | 67 + .../controllers/webapp_controller_test.exs | 23 + test/web/graphiql_test.exs | 12 + test/web/graphql_test.exs | 12 + webapp/.editorconfig | 32 + webapp/.ember-cli | 5 + webapp/.eslintignore | 8 + webapp/.eslintrc | 76 + webapp/.scss-lint.yml | 18 + webapp/.svgo.yml | 37 + webapp/.travis.yml | 28 + webapp/.watchmanconfig | 3 + webapp/app/app.js | 18 + webapp/app/component-helpers/percentage.js | 11 + webapp/app/computed-macros/parsed-key.js | 17 + webapp/app/helpers/string-diff.js | 26 + webapp/app/helpers/time-ago-in-words.js | 16 + webapp/app/index.html | 29 + .../app/instance-initializers/raven-setup.js | 17 + webapp/app/locales/en/config.js | 8 + webapp/app/locales/en/translations.js | 865 + webapp/app/mixins/apollo-route.js | 78 + webapp/app/mixins/authenticated-route.js | 12 + webapp/app/mixins/reset-scroll.js | 9 + webapp/app/pods/application/controller.js | 3 + webapp/app/pods/application/route.js | 27 + webapp/app/pods/application/template.hbs | 7 + .../pods/components/acc-badge/component.js | 5 + .../app/pods/components/acc-badge/styles.scss | 47 + .../components/acc-flash-message/component.js | 31 + .../components/acc-flash-message/styles.scss | 103 + .../components/acc-flash-message/template.hbs | 13 + .../pods/components/acc-modal/component.js | 9 + .../pods/components/acc-modal/template.hbs | 9 + .../components/activity-item/component.js | 134 + .../pods/components/activity-item/styles.scss | 606 + .../components/activity-item/template.hbs | 197 + .../application-footer/component.js | 3 + .../components/application-footer/styles.scss | 37 + .../application-footer/template.hbs | 12 + .../pods/components/async-button/component.js | 18 + .../pods/components/async-button/styles.scss | 48 + .../pods/components/async-button/template.hbs | 4 + .../pods/components/commit-file/component.js | 185 + .../pods/components/commit-file/styles.scss | 82 + .../pods/components/commit-file/template.hbs | 105 + .../components/conflict-item/component.js | 62 + .../pods/components/conflict-item/styles.scss | 213 + .../components/conflict-item/template.hbs | 96 + .../components/conflicts-filters/component.js | 74 + .../components/conflicts-filters/styles.scss | 66 + .../components/conflicts-filters/template.hbs | 54 + .../components/conflicts-items/component.js | 32 + .../components/conflicts-items/styles.scss | 19 + .../components/conflicts-items/template.hbs | 50 + .../components/conflicts-page/component.js | 5 + .../components/conflicts-page/styles.scss | 14 + .../components/conflicts-page/template.hbs | 39 + .../dashboard-features-list/component.js | 16 + .../dashboard-features-list/styles.scss | 96 + .../dashboard-features-list/template.hbs | 57 + .../dashboard-revision-progress/component.js | 32 + .../dashboard-revision-progress/styles.scss | 87 + .../dashboard-revision-progress/template.hbs | 28 + .../dashboard-revisions/component.js | 58 + .../dashboard-revisions/item/component.js | 67 + .../dashboard-revisions/item/styles.scss | 176 + .../dashboard-revisions/item/template.hbs | 60 + .../dashboard-revisions/styles.scss | 191 + .../dashboard-revisions/template.hbs | 151 + .../app/pods/components/date-tag/component.js | 24 + .../app/pods/components/date-tag/template.hbs | 3 + .../documents-add-button/styles.scss | 26 + .../documents-add-button/template.hbs | 8 + .../components/documents-list/component.js | 10 + .../documents-list/item/component.js | 34 + .../documents-list/item/template.hbs | 53 + .../components/documents-list/styles.scss | 93 + .../components/documents-list/template.hbs | 8 + .../components/dummy-login-form/component.js | 16 + .../components/dummy-login-form/styles.scss | 62 + .../components/dummy-login-form/template.hbs | 17 + .../components/empty-content/component.js | 7 + .../pods/components/empty-content/styles.scss | 48 + .../components/empty-content/template.hbs | 6 + .../components/error-section/component.js | 9 + .../pods/components/error-section/styles.scss | 67 + .../components/error-section/template.hbs | 22 + .../pods/components/file-export/component.js | 31 + .../pods/components/file-export/template.hbs | 1 + .../pods/components/file-input/component.js | 16 + .../flash-messages-list/component.js | 5 + .../flash-messages-list/styles.scss | 6 + .../flash-messages-list/template.hbs | 5 + .../components/google-login-form/component.js | 25 + .../components/google-login-form/styles.scss | 45 + .../components/google-login-form/template.hbs | 7 + .../components/loading-content/component.js | 5 + .../components/loading-content/styles.scss | 15 + .../components/loading-content/template.hbs | 9 + .../components/operations-peek/component.js | 5 + .../operations-peek/item/component.js | 35 + .../operations-peek/item/styles.scss | 104 + .../operations-peek/item/template.hbs | 51 + .../components/operations-peek/template.hbs | 5 + .../pods/components/page-title/component.js | 7 + .../pods/components/page-title/styles.scss | 21 + .../pods/components/page-title/template.hbs | 5 + .../phoenix-channel-listener/component.js | 27 + .../project-activities-filter/component.js | 86 + .../project-activities-filter/styles.scss | 16 + .../project-activities-filter/template.hbs | 41 + .../project-activities-list/component.js | 7 + .../project-activities-list/styles.scss | 15 + .../project-activities-list/template.hbs | 19 + .../components/project-activity/component.js | 135 + .../components/project-activity/styles.scss | 219 + .../components/project-activity/template.hbs | 227 + .../project-comments-list/component.js | 35 + .../project-comments-list/item/component.js | 11 + .../project-comments-list/item/styles.scss | 53 + .../project-comments-list/item/template.hbs | 31 + .../project-comments-list/styles.scss | 4 + .../project-comments-list/template.hbs | 5 + .../project-create-form/component.js | 69 + .../project-create-form/styles.scss | 60 + .../project-create-form/template.hbs | 57 + .../project-file-operation/styles.scss | 144 + .../project-navigation/component.js | 34 + .../project-navigation/list/component.js | 5 + .../project-navigation/list/styles.scss | 92 + .../project-navigation/list/template.hbs | 101 + .../components/project-navigation/styles.scss | 60 + .../project-navigation/template.hbs | 24 + .../project-settings/api-token/styles.scss | 35 + .../project-settings/api-token/template.hbs | 9 + .../project-settings/back-link/styles.scss | 3 + .../project-settings/back-link/template.hbs | 6 + .../project-settings/badges/component.js | 25 + .../project-settings/badges/styles.scss | 47 + .../project-settings/badges/template.hbs | 14 + .../collaborators/component.js | 18 + .../collaborators/create-form/component.js | 40 + .../collaborators/create-form/styles.scss | 27 + .../collaborators/create-form/template.hbs | 29 + .../collaborators/list/component.js | 23 + .../collaborators/list/item/component.js | 77 + .../collaborators/list/item/styles.scss | 71 + .../collaborators/list/item/template.hbs | 74 + .../collaborators/list/template.hbs | 10 + .../collaborators/styles.scss | 75 + .../collaborators/template.hbs | 46 + .../project-settings/delete-form/component.js | 12 + .../project-settings/delete-form/styles.scss | 36 + .../project-settings/delete-form/template.hbs | 19 + .../project-settings/form/component.js | 22 + .../project-settings/form/styles.scss | 84 + .../project-settings/form/template.hbs | 35 + .../integrations/component.js | 22 + .../integrations/form/component.js | 79 + .../integrations/form/styles.scss | 61 + .../integrations/form/template.hbs | 45 + .../integrations/list/component.js | 10 + .../integrations/list/item/component.js | 42 + .../integrations/list/item/styles.scss | 45 + .../integrations/list/item/template.hbs | 33 + .../integrations/list/styles.scss | 3 + .../integrations/list/template.hbs | 8 + .../project-settings/integrations/styles.scss | 29 + .../integrations/template.hbs | 32 + .../project-settings/links-list/styles.scss | 54 + .../project-settings/links-list/template.hbs | 41 + .../manage-languages/component.js | 12 + .../manage-languages/create-form/component.js | 57 + .../manage-languages/create-form/template.hbs | 19 + .../manage-languages/overview/component.js | 9 + .../overview/item/component.js | 52 + .../overview/item/template.hbs | 49 + .../manage-languages/overview/styles.scss | 82 + .../manage-languages/overview/template.hbs | 13 + .../manage-languages/styles.scss | 51 + .../manage-languages/template.hbs | 55 + .../project-settings/title/component.js | 5 + .../project-settings/title/styles.scss | 5 + .../project-settings/title/template.hbs | 1 + .../components/projects-filters/component.js | 30 + .../components/projects-filters/styles.scss | 45 + .../components/projects-filters/template.hbs | 22 + .../components/projects-header/component.js | 16 + .../components/projects-header/styles.scss | 123 + .../components/projects-header/template.hbs | 35 + .../components/projects-list/component.js | 6 + .../pods/components/projects-list/styles.scss | 37 + .../components/projects-list/template.hbs | 49 + .../quick-submit-textarea/component.js | 19 + .../related-translations-list/component.js | 6 + .../item/component.js | 42 + .../item/styles.scss | 126 + .../item/template.hbs | 90 + .../related-translations-list/styles.scss | 20 + .../related-translations-list/template.hbs | 26 + .../removed-translation-edit/component.js | 4 + .../removed-translation-edit/styles.scss | 21 + .../removed-translation-edit/template.hbs | 3 + .../resource-pagination/component.js | 26 + .../resource-pagination/styles.scss | 16 + .../resource-pagination/template.hbs | 15 + .../review-progress-bar/component.js | 14 + .../review-progress-bar/styles.scss | 14 + .../review-progress-bar/template.hbs | 5 + .../revision-export-options/component.js | 109 + .../revision-export-options/styles.scss | 47 + .../revision-export-options/template.hbs | 53 + .../components/revision-selector/component.js | 41 + .../components/revision-selector/styles.scss | 56 + .../components/revision-selector/template.hbs | 13 + .../skeleton-ui/activities-list/styles.scss | 78 + .../skeleton-ui/activities-list/template.hbs | 39 + .../skeleton-ui/conflicts-items/styles.scss | 67 + .../skeleton-ui/conflicts-items/template.hbs | 45 + .../skeleton-ui/documents-list/styles.scss | 44 + .../skeleton-ui/documents-list/template.hbs | 35 + .../skeleton-ui/progress-line/styles.scss | 25 + .../project-activities-filter/styles.scss | 3 + .../project-activities-filter/template.hbs | 1 + .../project-comments-list/styles.scss | 66 + .../project-comments-list/template.hbs | 62 + .../project-navigation/styles.scss | 37 + .../project-navigation/template.hbs | 17 + .../related-translations-list/styles.scss | 35 + .../related-translations-list/template.hbs | 20 + .../skeleton-ui/releases-list/styles.scss | 44 + .../skeleton-ui/releases-list/template.hbs | 35 + .../translation-comments-list/styles.scss | 42 + .../translation-comments-list/template.hbs | 20 + .../translation-splash-title/styles.scss | 16 + .../translation-splash-title/template.hbs | 2 + .../skeleton-ui/translations-list/styles.scss | 55 + .../translations-list/template.hbs | 35 + .../pods/components/spin-spinner/component.js | 39 + .../pods/components/spin-spinner/styles.scss | 4 + .../time-ago-in-words-tag/component.js | 26 + .../time-ago-in-words-tag/template.hbs | 1 + .../translation-activities-list/component.js | 7 + .../translation-activities-list/styles.scss | 16 + .../translation-activities-list/template.hbs | 11 + .../translation-comment-form/component.js | 34 + .../translation-comment-form/styles.scss | 40 + .../translation-comment-form/template.hbs | 24 + .../translation-comments-list/component.js | 5 + .../translation-comments-list/styles.scss | 93 + .../translation-comments-list/template.hbs | 27 + .../component.js | 15 + .../item/component.js | 35 + .../item/styles.scss | 45 + .../item/template.hbs | 10 + .../styles.scss | 17 + .../template.hbs | 9 + .../translation-conversation/component.js | 13 + .../translation-conversation/styles.scss | 14 + .../translation-conversation/template.hbs | 26 + .../components/translation-edit/component.js | 46 + .../translation-edit/form/component.js | 13 + .../translation-edit/form/styles.scss | 41 + .../translation-edit/form/template.hbs | 49 + .../components/translation-edit/styles.scss | 77 + .../components/translation-edit/template.hbs | 124 + .../translation-navigation/component.js | 7 + .../translation-navigation/template.hbs | 61 + .../translation-splash-title/component.js | 15 + .../translation-splash-title/styles.scss | 93 + .../translation-splash-title/template.hbs | 46 + .../translations-filter/component.js | 74 + .../translations-filter/styles.scss | 54 + .../translations-filter/template.hbs | 52 + .../components/translations-list/component.js | 9 + .../translations-list/item/component.js | 45 + .../translations-list/item/styles.scss | 155 + .../translations-list/item/template.hbs | 86 + .../components/translations-list/styles.scss | 3 + .../components/translations-list/template.hbs | 28 + .../version-create-form/component.js | 29 + .../version-create-form/styles.scss | 58 + .../version-create-form/template.hbs | 55 + .../version-update-form/component.js | 31 + .../version-update-form/styles.scss | 58 + .../version-update-form/template.hbs | 51 + .../versions-add-button/styles.scss | 26 + .../versions-add-button/template.hbs | 8 + .../components/versions-list/component.js | 10 + .../versions-list/item/component.js | 12 + .../versions-list/item/template.hbs | 54 + .../pods/components/versions-list/styles.scss | 73 + .../components/versions-list/template.hbs | 7 + .../components/welcome-project/styles.scss | 71 + .../components/welcome-project/template.hbs | 30 + webapp/app/pods/error/controller.js | 39 + webapp/app/pods/error/template.hbs | 7 + webapp/app/pods/index/route.js | 12 + webapp/app/pods/logged-in/controller.js | 7 + .../project/activities/controller.js | 43 + .../logged-in/project/activities/route.js | 56 + .../logged-in/project/activities/template.hbs | 41 + .../logged-in/project/activity/controller.js | 36 + .../pods/logged-in/project/activity/route.js | 29 + .../logged-in/project/activity/template.hbs | 12 + .../logged-in/project/comments/controller.js | 17 + .../pods/logged-in/project/comments/route.js | 39 + .../logged-in/project/comments/template.hbs | 22 + .../app/pods/logged-in/project/controller.js | 28 + .../project/edit/api-token/controller.js | 15 + .../logged-in/project/edit/api-token/route.js | 21 + .../project/edit/api-token/template.hbs | 9 + .../project/edit/badges/controller.js | 8 + .../logged-in/project/edit/badges/route.js | 27 + .../project/edit/badges/template.hbs | 7 + .../project/edit/collaborators/controller.js | 81 + .../project/edit/collaborators/route.js | 27 + .../project/edit/collaborators/template.hbs | 14 + .../project/edit/index/controller.js | 71 + .../logged-in/project/edit/index/route.js | 27 + .../logged-in/project/edit/index/template.hbs | 21 + .../edit/manage-languages/controller.js | 88 + .../project/edit/manage-languages/route.js | 36 + .../edit/manage-languages/template.hbs | 20 + .../edit/service-integrations/controller.js | 83 + .../edit/service-integrations/route.js | 27 + .../edit/service-integrations/template.hbs | 13 + .../pods/logged-in/project/edit/template.hbs | 3 + .../files/add-translations/controller.js | 73 + .../project/files/add-translations/route.js | 20 + .../files/add-translations/template.hbs | 50 + .../logged-in/project/files/controller.js | 42 + .../project/files/export/controller.js | 65 + .../logged-in/project/files/export/route.js | 37 + .../project/files/export/template.hbs | 55 + .../project/files/new-sync/controller.js | 59 + .../logged-in/project/files/new-sync/route.js | 15 + .../project/files/new-sync/template.hbs | 45 + .../app/pods/logged-in/project/files/route.js | 37 + .../project/files/sync/controller.js | 64 + .../logged-in/project/files/sync/route.js | 20 + .../logged-in/project/files/sync/template.hbs | 50 + .../pods/logged-in/project/files/template.hbs | 30 + .../logged-in/project/index/controller.js | 50 + .../app/pods/logged-in/project/index/route.js | 21 + .../pods/logged-in/project/index/template.hbs | 17 + .../project/revision/conflicts/controller.js | 101 + .../project/revision/conflicts/route.js | 81 + .../project/revision/conflicts/template.hbs | 22 + .../logged-in/project/revision/controller.js | 16 + .../full-screen-conflicts/template.hbs | 23 + .../pods/logged-in/project/revision/route.js | 12 + .../logged-in/project/revision/template.hbs | 7 + .../revision/translations/controller.js | 61 + .../project/revision/translations/route.js | 62 + .../revision/translations/template.hbs | 34 + webapp/app/pods/logged-in/project/route.js | 41 + .../app/pods/logged-in/project/template.hbs | 37 + .../translation/activities/controller.js | 22 + .../project/translation/activities/route.js | 47 + .../translation/activities/template.hbs | 18 + .../translation/comments/controller.js | 64 + .../project/translation/comments/route.js | 48 + .../project/translation/comments/template.hbs | 19 + .../project/translation/controller.js | 11 + .../project/translation/index/controller.js | 70 + .../project/translation/index/route.js | 7 + .../project/translation/index/template.hbs | 12 + .../related-translations/controller.js | 34 + .../translation/related-translations/route.js | 23 + .../related-translations/template.hbs | 13 + .../logged-in/project/translation/route.js | 23 + .../project/translation/template.hbs | 20 + .../logged-in/project/versions/controller.js | 22 + .../project/versions/edit/controller.js | 56 + .../logged-in/project/versions/edit/route.js | 12 + .../project/versions/edit/template.hbs | 8 + .../project/versions/export/controller.js | 73 + .../project/versions/export/route.js | 40 + .../project/versions/export/template.hbs | 64 + .../project/versions/new/controller.js | 49 + .../logged-in/project/versions/new/route.js | 15 + .../project/versions/new/template.hbs | 7 + .../pods/logged-in/project/versions/route.js | 38 + .../logged-in/project/versions/template.hbs | 29 + .../app/pods/logged-in/projects/controller.js | 26 + .../pods/logged-in/projects/new/controller.js | 33 + .../app/pods/logged-in/projects/new/route.js | 15 + .../pods/logged-in/projects/new/template.hbs | 7 + webapp/app/pods/logged-in/projects/route.js | 48 + .../app/pods/logged-in/projects/template.hbs | 22 + webapp/app/pods/logged-in/route.js | 12 + webapp/app/pods/logged-in/template.hbs | 3 + webapp/app/pods/login/controller.js | 33 + webapp/app/pods/login/route.js | 12 + webapp/app/pods/login/template.hbs | 9 + webapp/app/pods/not-found/controller.js | 13 + webapp/app/pods/not-found/template.hbs | 7 + webapp/app/pods/phoenix/service.js | 94 + .../app/queries/activity-activities.graphql | 67 + webapp/app/queries/conflicts.graphql | 40 + .../app/queries/correct-all-revision.graphql | 11 + .../app/queries/correct-translation.graphql | 13 + .../app/queries/create-collaborator.graphql | 9 + webapp/app/queries/create-comment.graphql | 10 + webapp/app/queries/create-integration.graphql | 9 + webapp/app/queries/create-project.graphql | 9 + webapp/app/queries/create-revision.graphql | 9 + ...-translation-comments-subscription.graphql | 9 + webapp/app/queries/create-version.graphql | 9 + .../app/queries/delete-collaborator.graphql | 9 + webapp/app/queries/delete-document.graphql | 9 + webapp/app/queries/delete-integration.graphql | 9 + webapp/app/queries/delete-project.graphql | 9 + webapp/app/queries/delete-revision.graphql | 9 + ...-translation-comments-subscription.graphql | 9 + webapp/app/queries/languages-search.graphql | 9 + webapp/app/queries/project-activities.graphql | 102 + webapp/app/queries/project-activity.graphql | 90 + webapp/app/queries/project-api-token.graphql | 9 + .../app/queries/project-collaborators.graphql | 29 + webapp/app/queries/project-comments.graphql | 38 + webapp/app/queries/project-dashboard.graphql | 98 + webapp/app/queries/project-documents.graphql | 27 + webapp/app/queries/project-edit.graphql | 9 + .../app/queries/project-new-language.graphql | 26 + .../project-service-integrations.graphql | 18 + webapp/app/queries/project-versions.graphql | 37 + webapp/app/queries/project.graphql | 39 + webapp/app/queries/projects.graphql | 30 + .../queries/promote-master-revision.graphql | 9 + .../app/queries/related-translations.graphql | 27 + webapp/app/queries/rollback-operation.graphql | 6 + .../queries/translation-activities.graphql | 77 + .../app/queries/translation-comments.graphql | 57 + webapp/app/queries/translation.graphql | 36 + webapp/app/queries/translations.graphql | 44 + .../queries/uncorrect-all-revision.graphql | 11 + .../app/queries/uncorrect-translation.graphql | 13 + .../app/queries/update-collaborator.graphql | 10 + webapp/app/queries/update-integration.graphql | 9 + webapp/app/queries/update-project.graphql | 11 + webapp/app/queries/update-translation.graphql | 13 + webapp/app/queries/update-version.graphql | 11 + webapp/app/resolver.js | 3 + webapp/app/router.js | 57 + webapp/app/services/apollo-mutate.js | 18 + webapp/app/services/apollo.js | 30 + webapp/app/services/authenticated-request.js | 63 + webapp/app/services/exporter.js | 36 + webapp/app/services/file-saver.js | 160 + webapp/app/services/global-state.js | 12 + webapp/app/services/language-searcher.js | 25 + webapp/app/services/merger.js | 19 + webapp/app/services/peeker.js | 57 + webapp/app/services/raven.js | 201 + webapp/app/services/session.js | 34 + webapp/app/services/session/creator.js | 23 + webapp/app/services/session/destroyer.js | 9 + webapp/app/services/session/fetcher.js | 11 + webapp/app/services/session/persister.js | 8 + webapp/app/services/syncer.js | 18 + webapp/app/styles/app.scss | 23 + webapp/app/styles/base.scss | 31 + webapp/app/styles/classes.scss | 38 + .../app/styles/html-components/filters.scss | 49 + .../styles/html-components/form/button.scss | 291 + .../styles/html-components/form/label.scss | 8 + .../styles/html-components/power-select.scss | 179 + .../html-components/sub-navigation.scss | 113 + .../app/styles/html-components/wrapper.scss | 49 + webapp/app/styles/modal.scss | 47 + webapp/app/styles/reset-select.scss | 30 + webapp/app/styles/reset.css | 141 + webapp/app/styles/variables/colors.scss | 23 + webapp/app/styles/variables/dimensions.scss | 5 + webapp/app/styles/variables/fonts.scss | 2 + webapp/app/styles/variables/power-select.scss | 50 + webapp/app/styles/variables/transitions.scss | 2 + webapp/app/utils/phoenix.js | 1329 ++ webapp/config/environment.js | 97 + webapp/ember-cli-build.js | 46 + webapp/package-lock.json | 13046 ++++++++++++++++ webapp/package.json | 75 + webapp/public/assets/activity.svg | 1 + webapp/public/assets/add.svg | 1 + webapp/public/assets/badge.svg | 1 + webapp/public/assets/bot.svg | 1 + webapp/public/assets/bubble.svg | 1 + webapp/public/assets/burger.svg | 1 + webapp/public/assets/check.svg | 1 + webapp/public/assets/chevron-left.svg | 1 + webapp/public/assets/chevron-right.svg | 1 + webapp/public/assets/chevron-top.svg | 1 + webapp/public/assets/empty.svg | 1 + webapp/public/assets/export.svg | 1 + webapp/public/assets/eye.svg | 1 + webapp/public/assets/favicon.png | Bin 0 -> 3671 bytes webapp/public/assets/file.svg | 1 + webapp/public/assets/fullscreen-minimize.svg | 1 + webapp/public/assets/fullscreen.svg | 1 + webapp/public/assets/gear.svg | 1 + webapp/public/assets/google-logo.png | Bin 0 -> 3560 bytes webapp/public/assets/home.svg | 1 + webapp/public/assets/language.svg | 1 + webapp/public/assets/loading.svg | 1 + webapp/public/assets/lock--locked.svg | 1 + webapp/public/assets/lock--unlocked.svg | 1 + webapp/public/assets/logo-bw.svg | 1 + webapp/public/assets/logo.svg | 1 + webapp/public/assets/merge.svg | 1 + webapp/public/assets/pencil.svg | 1 + webapp/public/assets/reload.svg | 1 + webapp/public/assets/revert.svg | 1 + webapp/public/assets/search.svg | 1 + webapp/public/assets/services/slack.svg | 1 + webapp/public/assets/share.svg | 1 + webapp/public/assets/sync.svg | 1 + webapp/public/assets/tag.svg | 1 + webapp/public/assets/thumbs-up.svg | 1 + webapp/public/assets/users.svg | 1 + webapp/public/assets/x.svg | 1 + webapp/public/crossdomain.xml | 15 + webapp/public/favicon.png | Bin 0 -> 3671 bytes webapp/public/robots.txt | 3 + webapp/testem.js | 17 + webapp/tests/.jshintrc | 52 + .../user-redirection-with-auth-status-test.js | 90 + webapp/tests/helpers/destroy-app.js | 7 + webapp/tests/helpers/flash-message.js | 6 + webapp/tests/helpers/module-for-acceptance.js | 25 + webapp/tests/helpers/resolver.js | 11 + webapp/tests/helpers/start-app.js | 22 + webapp/tests/index.html | 34 + webapp/tests/test-helper.js | 8 + .../unit/services/session/creator-test.js | 52 + .../unit/services/session/destroyer-test.js | 57 + .../unit/services/session/fetcher-test.js | 38 + .../unit/services/session/persister-test.js | 37 + 926 files changed, 53737 insertions(+) create mode 100644 .credo.exs create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 .hanzo.yml create mode 100644 .travis.yml create mode 100644 Aptfile create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 Procfile create mode 100644 README.md create mode 100644 compile create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/mailer.exs create mode 100644 config/prod.exs create mode 100644 config/test.exs create mode 100644 elixir_buildpack.config create mode 100644 lib/accent.ex create mode 100644 lib/accent/auth/canada_implementations.ex create mode 100644 lib/accent/auth/role_abilities.ex create mode 100644 lib/accent/auth/user_auth_fetcher.ex create mode 100644 lib/accent/auth/user_remote/adapter/fetcher.ex create mode 100644 lib/accent/auth/user_remote/adapter/user.ex create mode 100644 lib/accent/auth/user_remote/adapters/dummy.ex create mode 100644 lib/accent/auth/user_remote/adapters/google.ex create mode 100644 lib/accent/auth/user_remote/authenticator.ex create mode 100644 lib/accent/auth/user_remote/collaborator_normalizer.ex create mode 100644 lib/accent/auth/user_remote/fetcher.ex create mode 100644 lib/accent/auth/user_remote/persister.ex create mode 100644 lib/accent/auth/user_remote/token_giver.ex create mode 100644 lib/accent/badge_generator.ex create mode 100644 lib/accent/collaborators/collaborator_creator.ex create mode 100644 lib/accent/collaborators/collaborator_updater.ex create mode 100644 lib/accent/endpoint.ex create mode 100644 lib/accent/integrations/integration_manager.ex create mode 100644 lib/accent/mailer.ex create mode 100644 lib/accent/operations/operation_batcher.ex create mode 100644 lib/accent/projects/project_creator.ex create mode 100644 lib/accent/projects/project_deleter.ex create mode 100644 lib/accent/projects/project_updater.ex create mode 100644 lib/accent/repo.ex create mode 100644 lib/accent/revisions/revision_deleter.ex create mode 100644 lib/accent/revisions/revision_master_promoter.ex create mode 100644 lib/accent/schemas/access_token.ex create mode 100644 lib/accent/schemas/auth_provider.ex create mode 100644 lib/accent/schemas/collaborator.ex create mode 100644 lib/accent/schemas/comment.ex create mode 100644 lib/accent/schemas/document.ex create mode 100644 lib/accent/schemas/document_format.ex create mode 100644 lib/accent/schemas/integration.ex create mode 100644 lib/accent/schemas/integration_data.ex create mode 100644 lib/accent/schemas/language.ex create mode 100644 lib/accent/schemas/operation.ex create mode 100644 lib/accent/schemas/previous_translation.ex create mode 100644 lib/accent/schemas/project.ex create mode 100644 lib/accent/schemas/revision.ex create mode 100644 lib/accent/schemas/role.ex create mode 100644 lib/accent/schemas/schema.ex create mode 100644 lib/accent/schemas/translation.ex create mode 100644 lib/accent/schemas/translation_comments_subscription.ex create mode 100644 lib/accent/schemas/user.ex create mode 100644 lib/accent/schemas/version.ex create mode 100644 lib/accent/scopes/comment.ex create mode 100644 lib/accent/scopes/document.ex create mode 100644 lib/accent/scopes/language.ex create mode 100644 lib/accent/scopes/operation.ex create mode 100644 lib/accent/scopes/project.ex create mode 100644 lib/accent/scopes/revision.ex create mode 100644 lib/accent/scopes/translation.ex create mode 100644 lib/accent/scopes/version.ex create mode 100644 lib/accent/translations/translations_counter.ex create mode 100644 lib/accent/translations/translations_renderer.ex create mode 100644 lib/accessible.ex create mode 100644 lib/graphql/datetime_scalar.ex create mode 100644 lib/graphql/helpers/authorization.ex create mode 100644 lib/graphql/helpers/fields.ex create mode 100644 lib/graphql/mutations/collaborator.ex create mode 100644 lib/graphql/mutations/comment.ex create mode 100644 lib/graphql/mutations/document.ex create mode 100644 lib/graphql/mutations/integration.ex create mode 100644 lib/graphql/mutations/operation.ex create mode 100644 lib/graphql/mutations/project.ex create mode 100644 lib/graphql/mutations/revision.ex create mode 100644 lib/graphql/mutations/translation.ex create mode 100644 lib/graphql/mutations/version.ex create mode 100644 lib/graphql/paginated.ex create mode 100644 lib/graphql/plugins/authorization.ex create mode 100644 lib/graphql/resolvers/access_token.ex create mode 100644 lib/graphql/resolvers/activity.ex create mode 100644 lib/graphql/resolvers/collaborator.ex create mode 100644 lib/graphql/resolvers/comment.ex create mode 100644 lib/graphql/resolvers/document.ex create mode 100644 lib/graphql/resolvers/document_format.ex create mode 100644 lib/graphql/resolvers/integration.ex create mode 100644 lib/graphql/resolvers/language.ex create mode 100644 lib/graphql/resolvers/operation.ex create mode 100644 lib/graphql/resolvers/permission.ex create mode 100644 lib/graphql/resolvers/project.ex create mode 100644 lib/graphql/resolvers/revision.ex create mode 100644 lib/graphql/resolvers/role.ex create mode 100644 lib/graphql/resolvers/translation.ex create mode 100644 lib/graphql/resolvers/translation_comment_subscription.ex create mode 100644 lib/graphql/resolvers/version.ex create mode 100644 lib/graphql/resolvers/viewer.ex create mode 100644 lib/graphql/schema.ex create mode 100644 lib/graphql/types/activity.ex create mode 100644 lib/graphql/types/collaborator.ex create mode 100644 lib/graphql/types/comment.ex create mode 100644 lib/graphql/types/document.ex create mode 100644 lib/graphql/types/document_format.ex create mode 100644 lib/graphql/types/integration.ex create mode 100644 lib/graphql/types/language.ex create mode 100644 lib/graphql/types/mutation_result.ex create mode 100644 lib/graphql/types/pagination.ex create mode 100644 lib/graphql/types/project.ex create mode 100644 lib/graphql/types/revision.ex create mode 100644 lib/graphql/types/role.ex create mode 100644 lib/graphql/types/translation.ex create mode 100644 lib/graphql/types/user.ex create mode 100644 lib/graphql/types/version.ex create mode 100644 lib/graphql/types/viewer.ex create mode 100644 lib/hook/broadcaster.ex create mode 100644 lib/hook/consumers/email.ex create mode 100644 lib/hook/consumers/slack.ex create mode 100644 lib/hook/consumers/websocket.ex create mode 100644 lib/hook/context.ex create mode 100644 lib/hook/event_consumer.ex create mode 100644 lib/hook/event_producer.ex create mode 100644 lib/hook/producers/email.ex create mode 100644 lib/hook/producers/slack.ex create mode 100644 lib/hook/producers/websocket.ex create mode 100644 lib/langue/entry.ex create mode 100644 lib/langue/formatter/android/parser.ex create mode 100644 lib/langue/formatter/android/serializer.ex create mode 100644 lib/langue/formatter/es6_module/parser.ex create mode 100644 lib/langue/formatter/es6_module/serializer.ex create mode 100644 lib/langue/formatter/gettext/parser.ex create mode 100644 lib/langue/formatter/gettext/serializer.ex create mode 100644 lib/langue/formatter/java_properties/parser.ex create mode 100644 lib/langue/formatter/java_properties/serializer.ex create mode 100644 lib/langue/formatter/java_properties_xml/parser.ex create mode 100644 lib/langue/formatter/java_properties_xml/serializer.ex create mode 100644 lib/langue/formatter/json/parser.ex create mode 100644 lib/langue/formatter/json/serializer.ex create mode 100644 lib/langue/formatter/parser_result.ex create mode 100644 lib/langue/formatter/rails/parser.ex create mode 100644 lib/langue/formatter/rails/serializer.ex create mode 100644 lib/langue/formatter/serializer_result.ex create mode 100644 lib/langue/formatter/simple_json/parser.ex create mode 100644 lib/langue/formatter/simple_json/serializer.ex create mode 100644 lib/langue/formatter/strings/parser.ex create mode 100644 lib/langue/formatter/strings/serializer.ex create mode 100644 lib/langue/utils/line_by_line_helper.ex create mode 100644 lib/langue/utils/nested_parser_helper.ex create mode 100644 lib/langue/utils/nested_serializer_helper.ex create mode 100644 lib/langue/utils/parser.ex create mode 100644 lib/langue/utils/serializer.ex create mode 100644 lib/movement/builder.ex create mode 100644 lib/movement/builders/document_delete.ex create mode 100644 lib/movement/builders/new_slave.ex create mode 100644 lib/movement/builders/new_version.ex create mode 100644 lib/movement/builders/project_sync.ex create mode 100644 lib/movement/builders/revision_correct_all.ex create mode 100644 lib/movement/builders/revision_merge.ex create mode 100644 lib/movement/builders/revision_sync.ex create mode 100644 lib/movement/builders/revision_uncorrect_all.ex create mode 100644 lib/movement/builders/rollback.ex create mode 100644 lib/movement/builders/slave_conflict_sync.ex create mode 100644 lib/movement/builders/translation_correct_conflict.ex create mode 100644 lib/movement/builders/translation_uncorrect_conflict.ex create mode 100644 lib/movement/builders/translation_update.ex create mode 100644 lib/movement/comparer.ex create mode 100644 lib/movement/comparers/merge_force.ex create mode 100644 lib/movement/comparers/merge_passive.ex create mode 100644 lib/movement/comparers/merge_smart.ex create mode 100644 lib/movement/comparers/sync.ex create mode 100644 lib/movement/context.ex create mode 100644 lib/movement/ecto_migration_helper.ex create mode 100644 lib/movement/entries_commit_processor.ex create mode 100644 lib/movement/mappers/operation.ex create mode 100644 lib/movement/mappers/operations_stats.ex create mode 100644 lib/movement/migration.ex create mode 100644 lib/movement/migration/conflict.ex create mode 100644 lib/movement/migration/rollback.ex create mode 100644 lib/movement/migration/translation.ex create mode 100644 lib/movement/migrator.ex create mode 100644 lib/movement/migrator_macros.ex create mode 100644 lib/movement/operation.ex create mode 100644 lib/movement/persister.ex create mode 100644 lib/movement/persisters/base.ex create mode 100644 lib/movement/persisters/document_delete.ex create mode 100644 lib/movement/persisters/new_slave.ex create mode 100644 lib/movement/persisters/new_version.ex create mode 100644 lib/movement/persisters/project_sync.ex create mode 100644 lib/movement/persisters/revision_correct_all.ex create mode 100644 lib/movement/persisters/revision_merge.ex create mode 100644 lib/movement/persisters/revision_uncorrect_all.ex create mode 100644 lib/movement/persisters/rollback.ex create mode 100644 lib/movement/suggested_translation.ex create mode 100644 lib/movement/translation_comparer.ex create mode 100644 lib/utils/pretty_float.ex create mode 100644 lib/utils/secure_random.ex create mode 100644 lib/web/channels/project_channel.ex create mode 100644 lib/web/channels/user_socket.ex create mode 100644 lib/web/controllers/authentication_controller.ex create mode 100644 lib/web/controllers/badge_controller.ex create mode 100644 lib/web/controllers/error_controller.ex create mode 100644 lib/web/controllers/export_controller.ex create mode 100644 lib/web/controllers/merge_controller.ex create mode 100644 lib/web/controllers/peek_controller.ex create mode 100644 lib/web/controllers/sync_controller.ex create mode 100644 lib/web/controllers/webapp_controller.ex create mode 100644 lib/web/emails/create_comment_email.ex create mode 100644 lib/web/emails/project_invite_email.ex create mode 100644 lib/web/plugs/assign_current_user.ex create mode 100644 lib/web/plugs/bot_params_injector.ex create mode 100644 lib/web/plugs/ensure_unlocked_file_operations.ex create mode 100644 lib/web/plugs/graphql_context.ex create mode 100644 lib/web/plugs/movement_context_parser.ex create mode 100644 lib/web/plugs/revision_id_from_project_language.ex create mode 100644 lib/web/plugs/sentry_user_context.ex create mode 100644 lib/web/router.ex create mode 100644 lib/web/templates/badge/reviewed.svg.eex create mode 100644 lib/web/templates/email/create_comment.html.eex create mode 100644 lib/web/templates/email/create_comment.text.eex create mode 100644 lib/web/templates/email/project_invite.html.eex create mode 100644 lib/web/templates/email/project_invite.text.eex create mode 100644 lib/web/templates/email_layout/index.html.eex create mode 100644 lib/web/templates/email_layout/index.text.eex create mode 100644 lib/web/views/badge_view.ex create mode 100644 lib/web/views/email_layout_view.ex create mode 100644 lib/web/views/email_view.ex create mode 100644 lib/web/views/email_view_config_helper.ex create mode 100644 lib/web/views/email_view_style_helper.ex create mode 100644 lib/web/views/error_view.ex create mode 100644 lib/web/views/peek_view.ex create mode 100644 logo.svg create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 phoenix_static_buildpack.config create mode 100644 priv/repo/languages.json create mode 100644 priv/repo/migrations/20150929150000_create_languages.exs create mode 100644 priv/repo/migrations/20150929150001_create_projects.exs create mode 100644 priv/repo/migrations/20150929150002_create_revisions.exs create mode 100644 priv/repo/migrations/20150929160926_create_translations.exs create mode 100644 priv/repo/migrations/20151019020854_create_comments.exs create mode 100644 priv/repo/migrations/20151019020855_create_operations.exs create mode 100644 priv/repo/migrations/20151022000813_create_users.exs create mode 100644 priv/repo/migrations/20151026012626_add_user_id_to_operations.exs create mode 100644 priv/repo/migrations/20151217004603_add_unique_constraint_revisions_language_project.exs create mode 100644 priv/repo/migrations/20151221054134_add_user_id_to_comments.exs create mode 100644 priv/repo/migrations/20160112130538_add_comments_count_to_translations.exs create mode 100644 priv/repo/migrations/20160117013744_create_collaborators.exs create mode 100644 priv/repo/migrations/20160217231650_add_path_and_file_comment_to_translations.exs create mode 100644 priv/repo/migrations/20160217232101_add_path_and_file_comment_to_operations.exs create mode 100644 priv/repo/migrations/20160224154853_add_file_index_to_operations_and_translations.exs create mode 100644 priv/repo/migrations/20160225005143_add_rollbacked_to_operations.exs create mode 100644 priv/repo/migrations/20160301185757_add_bot_field_to_user.exs create mode 100644 priv/repo/migrations/20160315122640_add_last_synced_at_to_project.exs create mode 100644 priv/repo/migrations/20160317201856_create_documents.exs create mode 100644 priv/repo/migrations/20160413200143_update_translations_text_to_text_type.exs create mode 100644 priv/repo/migrations/20160414130613_add_value_type_for_translations.exs create mode 100644 priv/repo/migrations/20160508173942_change_length_of_uid_for_auth_provider.exs create mode 100644 priv/repo/migrations/20160605190558_add_stats_to_operations.exs create mode 100644 priv/repo/migrations/20161006003717_add_lock_file_operations_for_projects.exs create mode 100644 priv/repo/migrations/20161206005245_create_translation_comments_subscriptions.exs create mode 100644 priv/repo/migrations/20170415135352_create_integrations.exs create mode 100644 priv/repo/migrations/20170822120156_rename_from_operation_to_rollbacked_operation_id.exs create mode 100644 priv/repo/migrations/20171105171503_add_top_file_comment_and_header_to_document.exs create mode 100644 priv/repo/migrations/20180202190429_create_versions.exs create mode 100644 priv/repo/migrations/20180206220517_add_source_translation_id_to_translations.exs create mode 100644 priv/repo/migrations/20180209153309_add_locales_columns_on_languages.exs create mode 100644 priv/repo/migrations/20180219005702_add_picture_url_on_users.exs create mode 100644 priv/repo/migrations/20180322165819_drop_auth_application.exs create mode 100644 priv/repo/seeds.exs create mode 100755 priv/scripts/ci-check.sh create mode 100644 priv/static/images/accent.png create mode 100644 test/auth/user_remote/adapters/google_test.exs create mode 100644 test/auth/user_remote/authenticator_test.exs create mode 100644 test/auth/user_remote/fetcher_test.exs create mode 100644 test/auth/user_remote/persister_test.exs create mode 100644 test/auth/user_remote/token_giver_test.exs create mode 100644 test/badge_generator_test.exs create mode 100644 test/emails/create_comment_email_test.exs create mode 100644 test/emails/project_invite_email_test.exs create mode 100644 test/graphql/helpers/authorization_test.exs create mode 100644 test/graphql/helpers/fields_test.exs create mode 100644 test/graphql/resolvers/access_token_test.exs create mode 100644 test/graphql/resolvers/activity_test.exs create mode 100644 test/graphql/resolvers/collaborator_test.exs create mode 100644 test/graphql/resolvers/comment_test.exs create mode 100644 test/graphql/resolvers/document_test.exs create mode 100644 test/graphql/resolvers/integration_test.exs create mode 100644 test/graphql/resolvers/language_test.exs create mode 100644 test/graphql/resolvers/permission_test.exs create mode 100644 test/graphql/resolvers/project_test.exs create mode 100644 test/graphql/resolvers/revision_test.exs create mode 100644 test/graphql/resolvers/translation_comment_subscription_test.exs create mode 100644 test/graphql/resolvers/translation_test.exs create mode 100644 test/graphql/resolvers/version_test.exs create mode 100644 test/graphql/resolvers/viewer_test.exs create mode 100644 test/hook/consumers/slack_test.exs create mode 100644 test/hook/producers/slack_test.exs create mode 100644 test/langue/android/expectation_test.exs create mode 100644 test/langue/android/formatter_test.exs create mode 100644 test/langue/es6_module/expectation_test.exs create mode 100644 test/langue/es6_module/formatter_test.exs create mode 100644 test/langue/gettext/expectation_test.exs create mode 100644 test/langue/gettext/formatter_test.exs create mode 100644 test/langue/java_properties/expectation_test.exs create mode 100644 test/langue/java_properties/formatter_test.exs create mode 100644 test/langue/java_properties_xml/expectation_test.exs create mode 100644 test/langue/java_properties_xml/formatter_test.exs create mode 100644 test/langue/json/expectation_test.exs create mode 100644 test/langue/json/formatter_test.exs create mode 100644 test/langue/rails/expectation_test.exs create mode 100644 test/langue/rails/formatter_test.exs create mode 100644 test/langue/simple_json/expectation_test.exs create mode 100644 test/langue/simple_json/formatter_test.exs create mode 100644 test/langue/strings/expectation_test.exs create mode 100644 test/langue/strings/formatter_test.exs create mode 100644 test/movement/builders/new_slave_test.exs create mode 100644 test/movement/builders/new_version_test.exs create mode 100644 test/movement/builders/project_sync_test.exs create mode 100644 test/movement/builders/revision_correct_all_test.exs create mode 100644 test/movement/builders/revision_merge_test.exs create mode 100644 test/movement/builders/revision_sync_test.exs create mode 100644 test/movement/builders/revision_uncorrect_all_test.exs create mode 100644 test/movement/builders/rollback_test.exs create mode 100644 test/movement/builders/slave_conflict_sync_test.exs create mode 100644 test/movement/builders/translation_correct_conflict_test.exs create mode 100644 test/movement/builders/translation_uncorrect_conflict_test.exs create mode 100644 test/movement/builders/translation_update_test.exs create mode 100644 test/movement/comparers/merge_force_test.exs create mode 100644 test/movement/comparers/merge_passive_test.exs create mode 100644 test/movement/comparers/merge_smart_test.exs create mode 100644 test/movement/comparers/sync_test.exs create mode 100644 test/movement/down_test.exs create mode 100644 test/movement/persisters/base_test.exs create mode 100644 test/movement/persisters/new_slave_test.exs create mode 100644 test/movement/persisters/project_sync_test.exs create mode 100644 test/movement/persisters/rollback_test.exs create mode 100644 test/movement/translation_comparer_test.exs create mode 100644 test/movement/up_test.exs create mode 100644 test/plugs/assign_current_user_test.exs create mode 100644 test/plugs/bot_params_injector_test.exs create mode 100644 test/plugs/ensure_unlocked_file_operations_test.exs create mode 100644 test/plugs/graphql_context_test.exs create mode 100644 test/plugs/movement_context_parser_test.exs create mode 100644 test/pretty_float_test.exs create mode 100644 test/schemas/document_format_test.exs create mode 100644 test/schemas/previous_translation_test.exs create mode 100644 test/schemas/role_test.exs create mode 100644 test/schemas/user_test.exs create mode 100644 test/scopes/comment_test.exs create mode 100644 test/scopes/document_test.exs create mode 100644 test/scopes/language_test.exs create mode 100644 test/scopes/operation_test.exs create mode 100644 test/scopes/project_test.exs create mode 100644 test/scopes/revision_test.exs create mode 100644 test/scopes/translation_test.exs create mode 100644 test/scopes/version_test.exs create mode 100644 test/services/collaborator_creator_test.exs create mode 100644 test/services/collaborator_normalizer_test.exs create mode 100644 test/services/collaborator_updater_test.exs create mode 100644 test/services/operation_batcher_test.exs create mode 100644 test/services/project_creator_test.exs create mode 100644 test/services/project_deleter_test.exs create mode 100644 test/services/revision_deleter_test.exs create mode 100644 test/services/revision_master_promoter_test.exs create mode 100644 test/services/translations_renderer_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/support/formatter/gettext/simple.po create mode 100644 test/support/formatter/json/simple.json create mode 100644 test/support/invalid_file.json create mode 100644 test/support/mocks.ex create mode 100644 test/support/repo_case.ex create mode 100644 test/test_helper.exs create mode 100644 test/web/controllers/authentication_controller_test.exs create mode 100644 test/web/controllers/badge_controller_test.exs create mode 100644 test/web/controllers/error_controller_test.exs create mode 100644 test/web/controllers/export_controller_test.exs create mode 100644 test/web/controllers/merge_controller_test.exs create mode 100644 test/web/controllers/peek_controller_test.exs create mode 100644 test/web/controllers/sync_controller_test.exs create mode 100644 test/web/controllers/webapp_controller_test.exs create mode 100644 test/web/graphiql_test.exs create mode 100644 test/web/graphql_test.exs create mode 100644 webapp/.editorconfig create mode 100644 webapp/.ember-cli create mode 100644 webapp/.eslintignore create mode 100644 webapp/.eslintrc create mode 100644 webapp/.scss-lint.yml create mode 100644 webapp/.svgo.yml create mode 100644 webapp/.travis.yml create mode 100644 webapp/.watchmanconfig create mode 100644 webapp/app/app.js create mode 100644 webapp/app/component-helpers/percentage.js create mode 100644 webapp/app/computed-macros/parsed-key.js create mode 100644 webapp/app/helpers/string-diff.js create mode 100644 webapp/app/helpers/time-ago-in-words.js create mode 100644 webapp/app/index.html create mode 100644 webapp/app/instance-initializers/raven-setup.js create mode 100644 webapp/app/locales/en/config.js create mode 100644 webapp/app/locales/en/translations.js create mode 100644 webapp/app/mixins/apollo-route.js create mode 100644 webapp/app/mixins/authenticated-route.js create mode 100644 webapp/app/mixins/reset-scroll.js create mode 100644 webapp/app/pods/application/controller.js create mode 100644 webapp/app/pods/application/route.js create mode 100644 webapp/app/pods/application/template.hbs create mode 100644 webapp/app/pods/components/acc-badge/component.js create mode 100644 webapp/app/pods/components/acc-badge/styles.scss create mode 100644 webapp/app/pods/components/acc-flash-message/component.js create mode 100644 webapp/app/pods/components/acc-flash-message/styles.scss create mode 100644 webapp/app/pods/components/acc-flash-message/template.hbs create mode 100644 webapp/app/pods/components/acc-modal/component.js create mode 100644 webapp/app/pods/components/acc-modal/template.hbs create mode 100644 webapp/app/pods/components/activity-item/component.js create mode 100644 webapp/app/pods/components/activity-item/styles.scss create mode 100644 webapp/app/pods/components/activity-item/template.hbs create mode 100644 webapp/app/pods/components/application-footer/component.js create mode 100644 webapp/app/pods/components/application-footer/styles.scss create mode 100644 webapp/app/pods/components/application-footer/template.hbs create mode 100644 webapp/app/pods/components/async-button/component.js create mode 100644 webapp/app/pods/components/async-button/styles.scss create mode 100644 webapp/app/pods/components/async-button/template.hbs create mode 100644 webapp/app/pods/components/commit-file/component.js create mode 100644 webapp/app/pods/components/commit-file/styles.scss create mode 100644 webapp/app/pods/components/commit-file/template.hbs create mode 100644 webapp/app/pods/components/conflict-item/component.js create mode 100644 webapp/app/pods/components/conflict-item/styles.scss create mode 100644 webapp/app/pods/components/conflict-item/template.hbs create mode 100644 webapp/app/pods/components/conflicts-filters/component.js create mode 100644 webapp/app/pods/components/conflicts-filters/styles.scss create mode 100644 webapp/app/pods/components/conflicts-filters/template.hbs create mode 100644 webapp/app/pods/components/conflicts-items/component.js create mode 100644 webapp/app/pods/components/conflicts-items/styles.scss create mode 100644 webapp/app/pods/components/conflicts-items/template.hbs create mode 100644 webapp/app/pods/components/conflicts-page/component.js create mode 100644 webapp/app/pods/components/conflicts-page/styles.scss create mode 100644 webapp/app/pods/components/conflicts-page/template.hbs create mode 100644 webapp/app/pods/components/dashboard-features-list/component.js create mode 100644 webapp/app/pods/components/dashboard-features-list/styles.scss create mode 100644 webapp/app/pods/components/dashboard-features-list/template.hbs create mode 100644 webapp/app/pods/components/dashboard-revision-progress/component.js create mode 100644 webapp/app/pods/components/dashboard-revision-progress/styles.scss create mode 100644 webapp/app/pods/components/dashboard-revision-progress/template.hbs create mode 100644 webapp/app/pods/components/dashboard-revisions/component.js create mode 100644 webapp/app/pods/components/dashboard-revisions/item/component.js create mode 100644 webapp/app/pods/components/dashboard-revisions/item/styles.scss create mode 100644 webapp/app/pods/components/dashboard-revisions/item/template.hbs create mode 100644 webapp/app/pods/components/dashboard-revisions/styles.scss create mode 100644 webapp/app/pods/components/dashboard-revisions/template.hbs create mode 100644 webapp/app/pods/components/date-tag/component.js create mode 100644 webapp/app/pods/components/date-tag/template.hbs create mode 100644 webapp/app/pods/components/documents-add-button/styles.scss create mode 100644 webapp/app/pods/components/documents-add-button/template.hbs create mode 100644 webapp/app/pods/components/documents-list/component.js create mode 100644 webapp/app/pods/components/documents-list/item/component.js create mode 100644 webapp/app/pods/components/documents-list/item/template.hbs create mode 100644 webapp/app/pods/components/documents-list/styles.scss create mode 100644 webapp/app/pods/components/documents-list/template.hbs create mode 100644 webapp/app/pods/components/dummy-login-form/component.js create mode 100644 webapp/app/pods/components/dummy-login-form/styles.scss create mode 100644 webapp/app/pods/components/dummy-login-form/template.hbs create mode 100644 webapp/app/pods/components/empty-content/component.js create mode 100644 webapp/app/pods/components/empty-content/styles.scss create mode 100644 webapp/app/pods/components/empty-content/template.hbs create mode 100644 webapp/app/pods/components/error-section/component.js create mode 100644 webapp/app/pods/components/error-section/styles.scss create mode 100644 webapp/app/pods/components/error-section/template.hbs create mode 100644 webapp/app/pods/components/file-export/component.js create mode 100644 webapp/app/pods/components/file-export/template.hbs create mode 100644 webapp/app/pods/components/file-input/component.js create mode 100644 webapp/app/pods/components/flash-messages-list/component.js create mode 100644 webapp/app/pods/components/flash-messages-list/styles.scss create mode 100644 webapp/app/pods/components/flash-messages-list/template.hbs create mode 100644 webapp/app/pods/components/google-login-form/component.js create mode 100644 webapp/app/pods/components/google-login-form/styles.scss create mode 100644 webapp/app/pods/components/google-login-form/template.hbs create mode 100644 webapp/app/pods/components/loading-content/component.js create mode 100644 webapp/app/pods/components/loading-content/styles.scss create mode 100644 webapp/app/pods/components/loading-content/template.hbs create mode 100644 webapp/app/pods/components/operations-peek/component.js create mode 100644 webapp/app/pods/components/operations-peek/item/component.js create mode 100644 webapp/app/pods/components/operations-peek/item/styles.scss create mode 100644 webapp/app/pods/components/operations-peek/item/template.hbs create mode 100644 webapp/app/pods/components/operations-peek/template.hbs create mode 100644 webapp/app/pods/components/page-title/component.js create mode 100644 webapp/app/pods/components/page-title/styles.scss create mode 100644 webapp/app/pods/components/page-title/template.hbs create mode 100644 webapp/app/pods/components/phoenix-channel-listener/component.js create mode 100644 webapp/app/pods/components/project-activities-filter/component.js create mode 100644 webapp/app/pods/components/project-activities-filter/styles.scss create mode 100644 webapp/app/pods/components/project-activities-filter/template.hbs create mode 100644 webapp/app/pods/components/project-activities-list/component.js create mode 100644 webapp/app/pods/components/project-activities-list/styles.scss create mode 100644 webapp/app/pods/components/project-activities-list/template.hbs create mode 100644 webapp/app/pods/components/project-activity/component.js create mode 100644 webapp/app/pods/components/project-activity/styles.scss create mode 100644 webapp/app/pods/components/project-activity/template.hbs create mode 100644 webapp/app/pods/components/project-comments-list/component.js create mode 100644 webapp/app/pods/components/project-comments-list/item/component.js create mode 100644 webapp/app/pods/components/project-comments-list/item/styles.scss create mode 100644 webapp/app/pods/components/project-comments-list/item/template.hbs create mode 100644 webapp/app/pods/components/project-comments-list/styles.scss create mode 100644 webapp/app/pods/components/project-comments-list/template.hbs create mode 100644 webapp/app/pods/components/project-create-form/component.js create mode 100644 webapp/app/pods/components/project-create-form/styles.scss create mode 100644 webapp/app/pods/components/project-create-form/template.hbs create mode 100644 webapp/app/pods/components/project-file-operation/styles.scss create mode 100644 webapp/app/pods/components/project-navigation/component.js create mode 100644 webapp/app/pods/components/project-navigation/list/component.js create mode 100644 webapp/app/pods/components/project-navigation/list/styles.scss create mode 100644 webapp/app/pods/components/project-navigation/list/template.hbs create mode 100644 webapp/app/pods/components/project-navigation/styles.scss create mode 100644 webapp/app/pods/components/project-navigation/template.hbs create mode 100644 webapp/app/pods/components/project-settings/api-token/styles.scss create mode 100644 webapp/app/pods/components/project-settings/api-token/template.hbs create mode 100644 webapp/app/pods/components/project-settings/back-link/styles.scss create mode 100644 webapp/app/pods/components/project-settings/back-link/template.hbs create mode 100644 webapp/app/pods/components/project-settings/badges/component.js create mode 100644 webapp/app/pods/components/project-settings/badges/styles.scss create mode 100644 webapp/app/pods/components/project-settings/badges/template.hbs create mode 100644 webapp/app/pods/components/project-settings/collaborators/component.js create mode 100644 webapp/app/pods/components/project-settings/collaborators/create-form/component.js create mode 100644 webapp/app/pods/components/project-settings/collaborators/create-form/styles.scss create mode 100644 webapp/app/pods/components/project-settings/collaborators/create-form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/collaborators/list/component.js create mode 100644 webapp/app/pods/components/project-settings/collaborators/list/item/component.js create mode 100644 webapp/app/pods/components/project-settings/collaborators/list/item/styles.scss create mode 100644 webapp/app/pods/components/project-settings/collaborators/list/item/template.hbs create mode 100644 webapp/app/pods/components/project-settings/collaborators/list/template.hbs create mode 100644 webapp/app/pods/components/project-settings/collaborators/styles.scss create mode 100644 webapp/app/pods/components/project-settings/collaborators/template.hbs create mode 100644 webapp/app/pods/components/project-settings/delete-form/component.js create mode 100644 webapp/app/pods/components/project-settings/delete-form/styles.scss create mode 100644 webapp/app/pods/components/project-settings/delete-form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/form/component.js create mode 100644 webapp/app/pods/components/project-settings/form/styles.scss create mode 100644 webapp/app/pods/components/project-settings/form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/component.js create mode 100644 webapp/app/pods/components/project-settings/integrations/form/component.js create mode 100644 webapp/app/pods/components/project-settings/integrations/form/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/list/component.js create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/component.js create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/list/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/list/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/template.hbs create mode 100644 webapp/app/pods/components/project-settings/links-list/styles.scss create mode 100644 webapp/app/pods/components/project-settings/links-list/template.hbs create mode 100644 webapp/app/pods/components/project-settings/manage-languages/component.js create mode 100644 webapp/app/pods/components/project-settings/manage-languages/create-form/component.js create mode 100644 webapp/app/pods/components/project-settings/manage-languages/create-form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/manage-languages/overview/component.js create mode 100644 webapp/app/pods/components/project-settings/manage-languages/overview/item/component.js create mode 100644 webapp/app/pods/components/project-settings/manage-languages/overview/item/template.hbs create mode 100644 webapp/app/pods/components/project-settings/manage-languages/overview/styles.scss create mode 100644 webapp/app/pods/components/project-settings/manage-languages/overview/template.hbs create mode 100644 webapp/app/pods/components/project-settings/manage-languages/styles.scss create mode 100644 webapp/app/pods/components/project-settings/manage-languages/template.hbs create mode 100644 webapp/app/pods/components/project-settings/title/component.js create mode 100644 webapp/app/pods/components/project-settings/title/styles.scss create mode 100644 webapp/app/pods/components/project-settings/title/template.hbs create mode 100644 webapp/app/pods/components/projects-filters/component.js create mode 100644 webapp/app/pods/components/projects-filters/styles.scss create mode 100644 webapp/app/pods/components/projects-filters/template.hbs create mode 100644 webapp/app/pods/components/projects-header/component.js create mode 100644 webapp/app/pods/components/projects-header/styles.scss create mode 100644 webapp/app/pods/components/projects-header/template.hbs create mode 100644 webapp/app/pods/components/projects-list/component.js create mode 100644 webapp/app/pods/components/projects-list/styles.scss create mode 100644 webapp/app/pods/components/projects-list/template.hbs create mode 100644 webapp/app/pods/components/quick-submit-textarea/component.js create mode 100644 webapp/app/pods/components/related-translations-list/component.js create mode 100644 webapp/app/pods/components/related-translations-list/item/component.js create mode 100644 webapp/app/pods/components/related-translations-list/item/styles.scss create mode 100644 webapp/app/pods/components/related-translations-list/item/template.hbs create mode 100644 webapp/app/pods/components/related-translations-list/styles.scss create mode 100644 webapp/app/pods/components/related-translations-list/template.hbs create mode 100644 webapp/app/pods/components/removed-translation-edit/component.js create mode 100644 webapp/app/pods/components/removed-translation-edit/styles.scss create mode 100644 webapp/app/pods/components/removed-translation-edit/template.hbs create mode 100644 webapp/app/pods/components/resource-pagination/component.js create mode 100644 webapp/app/pods/components/resource-pagination/styles.scss create mode 100644 webapp/app/pods/components/resource-pagination/template.hbs create mode 100644 webapp/app/pods/components/review-progress-bar/component.js create mode 100644 webapp/app/pods/components/review-progress-bar/styles.scss create mode 100644 webapp/app/pods/components/review-progress-bar/template.hbs create mode 100644 webapp/app/pods/components/revision-export-options/component.js create mode 100644 webapp/app/pods/components/revision-export-options/styles.scss create mode 100644 webapp/app/pods/components/revision-export-options/template.hbs create mode 100644 webapp/app/pods/components/revision-selector/component.js create mode 100644 webapp/app/pods/components/revision-selector/styles.scss create mode 100644 webapp/app/pods/components/revision-selector/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/activities-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/activities-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/conflicts-items/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/conflicts-items/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/documents-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/documents-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/progress-line/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/project-activities-filter/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/project-activities-filter/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/project-comments-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/project-comments-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/project-navigation/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/project-navigation/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/related-translations-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/related-translations-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/releases-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/releases-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/translation-comments-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/translation-comments-list/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/translation-splash-title/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/translation-splash-title/template.hbs create mode 100644 webapp/app/pods/components/skeleton-ui/translations-list/styles.scss create mode 100644 webapp/app/pods/components/skeleton-ui/translations-list/template.hbs create mode 100644 webapp/app/pods/components/spin-spinner/component.js create mode 100644 webapp/app/pods/components/spin-spinner/styles.scss create mode 100644 webapp/app/pods/components/time-ago-in-words-tag/component.js create mode 100644 webapp/app/pods/components/time-ago-in-words-tag/template.hbs create mode 100644 webapp/app/pods/components/translation-activities-list/component.js create mode 100644 webapp/app/pods/components/translation-activities-list/styles.scss create mode 100644 webapp/app/pods/components/translation-activities-list/template.hbs create mode 100644 webapp/app/pods/components/translation-comment-form/component.js create mode 100644 webapp/app/pods/components/translation-comment-form/styles.scss create mode 100644 webapp/app/pods/components/translation-comment-form/template.hbs create mode 100644 webapp/app/pods/components/translation-comments-list/component.js create mode 100644 webapp/app/pods/components/translation-comments-list/styles.scss create mode 100644 webapp/app/pods/components/translation-comments-list/template.hbs create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/component.js create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/item/component.js create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/item/styles.scss create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/item/template.hbs create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/styles.scss create mode 100644 webapp/app/pods/components/translation-comments-subscriptions/template.hbs create mode 100644 webapp/app/pods/components/translation-conversation/component.js create mode 100644 webapp/app/pods/components/translation-conversation/styles.scss create mode 100644 webapp/app/pods/components/translation-conversation/template.hbs create mode 100644 webapp/app/pods/components/translation-edit/component.js create mode 100644 webapp/app/pods/components/translation-edit/form/component.js create mode 100644 webapp/app/pods/components/translation-edit/form/styles.scss create mode 100644 webapp/app/pods/components/translation-edit/form/template.hbs create mode 100644 webapp/app/pods/components/translation-edit/styles.scss create mode 100644 webapp/app/pods/components/translation-edit/template.hbs create mode 100644 webapp/app/pods/components/translation-navigation/component.js create mode 100644 webapp/app/pods/components/translation-navigation/template.hbs create mode 100644 webapp/app/pods/components/translation-splash-title/component.js create mode 100644 webapp/app/pods/components/translation-splash-title/styles.scss create mode 100644 webapp/app/pods/components/translation-splash-title/template.hbs create mode 100644 webapp/app/pods/components/translations-filter/component.js create mode 100644 webapp/app/pods/components/translations-filter/styles.scss create mode 100644 webapp/app/pods/components/translations-filter/template.hbs create mode 100644 webapp/app/pods/components/translations-list/component.js create mode 100644 webapp/app/pods/components/translations-list/item/component.js create mode 100644 webapp/app/pods/components/translations-list/item/styles.scss create mode 100644 webapp/app/pods/components/translations-list/item/template.hbs create mode 100644 webapp/app/pods/components/translations-list/styles.scss create mode 100644 webapp/app/pods/components/translations-list/template.hbs create mode 100644 webapp/app/pods/components/version-create-form/component.js create mode 100644 webapp/app/pods/components/version-create-form/styles.scss create mode 100644 webapp/app/pods/components/version-create-form/template.hbs create mode 100644 webapp/app/pods/components/version-update-form/component.js create mode 100644 webapp/app/pods/components/version-update-form/styles.scss create mode 100644 webapp/app/pods/components/version-update-form/template.hbs create mode 100644 webapp/app/pods/components/versions-add-button/styles.scss create mode 100644 webapp/app/pods/components/versions-add-button/template.hbs create mode 100644 webapp/app/pods/components/versions-list/component.js create mode 100644 webapp/app/pods/components/versions-list/item/component.js create mode 100644 webapp/app/pods/components/versions-list/item/template.hbs create mode 100644 webapp/app/pods/components/versions-list/styles.scss create mode 100644 webapp/app/pods/components/versions-list/template.hbs create mode 100644 webapp/app/pods/components/welcome-project/styles.scss create mode 100644 webapp/app/pods/components/welcome-project/template.hbs create mode 100644 webapp/app/pods/error/controller.js create mode 100644 webapp/app/pods/error/template.hbs create mode 100644 webapp/app/pods/index/route.js create mode 100644 webapp/app/pods/logged-in/controller.js create mode 100644 webapp/app/pods/logged-in/project/activities/controller.js create mode 100644 webapp/app/pods/logged-in/project/activities/route.js create mode 100644 webapp/app/pods/logged-in/project/activities/template.hbs create mode 100644 webapp/app/pods/logged-in/project/activity/controller.js create mode 100644 webapp/app/pods/logged-in/project/activity/route.js create mode 100644 webapp/app/pods/logged-in/project/activity/template.hbs create mode 100644 webapp/app/pods/logged-in/project/comments/controller.js create mode 100644 webapp/app/pods/logged-in/project/comments/route.js create mode 100644 webapp/app/pods/logged-in/project/comments/template.hbs create mode 100644 webapp/app/pods/logged-in/project/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/api-token/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/api-token/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/api-token/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/badges/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/badges/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/badges/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/collaborators/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/collaborators/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/collaborators/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/index/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/index/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/index/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/manage-languages/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/manage-languages/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/manage-languages/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/service-integrations/controller.js create mode 100644 webapp/app/pods/logged-in/project/edit/service-integrations/route.js create mode 100644 webapp/app/pods/logged-in/project/edit/service-integrations/template.hbs create mode 100644 webapp/app/pods/logged-in/project/edit/template.hbs create mode 100644 webapp/app/pods/logged-in/project/files/add-translations/controller.js create mode 100644 webapp/app/pods/logged-in/project/files/add-translations/route.js create mode 100644 webapp/app/pods/logged-in/project/files/add-translations/template.hbs create mode 100644 webapp/app/pods/logged-in/project/files/controller.js create mode 100644 webapp/app/pods/logged-in/project/files/export/controller.js create mode 100644 webapp/app/pods/logged-in/project/files/export/route.js create mode 100644 webapp/app/pods/logged-in/project/files/export/template.hbs create mode 100644 webapp/app/pods/logged-in/project/files/new-sync/controller.js create mode 100644 webapp/app/pods/logged-in/project/files/new-sync/route.js create mode 100644 webapp/app/pods/logged-in/project/files/new-sync/template.hbs create mode 100644 webapp/app/pods/logged-in/project/files/route.js create mode 100644 webapp/app/pods/logged-in/project/files/sync/controller.js create mode 100644 webapp/app/pods/logged-in/project/files/sync/route.js create mode 100644 webapp/app/pods/logged-in/project/files/sync/template.hbs create mode 100644 webapp/app/pods/logged-in/project/files/template.hbs create mode 100644 webapp/app/pods/logged-in/project/index/controller.js create mode 100644 webapp/app/pods/logged-in/project/index/route.js create mode 100644 webapp/app/pods/logged-in/project/index/template.hbs create mode 100644 webapp/app/pods/logged-in/project/revision/conflicts/controller.js create mode 100644 webapp/app/pods/logged-in/project/revision/conflicts/route.js create mode 100644 webapp/app/pods/logged-in/project/revision/conflicts/template.hbs create mode 100644 webapp/app/pods/logged-in/project/revision/controller.js create mode 100644 webapp/app/pods/logged-in/project/revision/full-screen-conflicts/template.hbs create mode 100644 webapp/app/pods/logged-in/project/revision/route.js create mode 100644 webapp/app/pods/logged-in/project/revision/template.hbs create mode 100644 webapp/app/pods/logged-in/project/revision/translations/controller.js create mode 100644 webapp/app/pods/logged-in/project/revision/translations/route.js create mode 100644 webapp/app/pods/logged-in/project/revision/translations/template.hbs create mode 100644 webapp/app/pods/logged-in/project/route.js create mode 100644 webapp/app/pods/logged-in/project/template.hbs create mode 100644 webapp/app/pods/logged-in/project/translation/activities/controller.js create mode 100644 webapp/app/pods/logged-in/project/translation/activities/route.js create mode 100644 webapp/app/pods/logged-in/project/translation/activities/template.hbs create mode 100644 webapp/app/pods/logged-in/project/translation/comments/controller.js create mode 100644 webapp/app/pods/logged-in/project/translation/comments/route.js create mode 100644 webapp/app/pods/logged-in/project/translation/comments/template.hbs create mode 100644 webapp/app/pods/logged-in/project/translation/controller.js create mode 100644 webapp/app/pods/logged-in/project/translation/index/controller.js create mode 100644 webapp/app/pods/logged-in/project/translation/index/route.js create mode 100644 webapp/app/pods/logged-in/project/translation/index/template.hbs create mode 100644 webapp/app/pods/logged-in/project/translation/related-translations/controller.js create mode 100644 webapp/app/pods/logged-in/project/translation/related-translations/route.js create mode 100644 webapp/app/pods/logged-in/project/translation/related-translations/template.hbs create mode 100644 webapp/app/pods/logged-in/project/translation/route.js create mode 100644 webapp/app/pods/logged-in/project/translation/template.hbs create mode 100644 webapp/app/pods/logged-in/project/versions/controller.js create mode 100644 webapp/app/pods/logged-in/project/versions/edit/controller.js create mode 100644 webapp/app/pods/logged-in/project/versions/edit/route.js create mode 100644 webapp/app/pods/logged-in/project/versions/edit/template.hbs create mode 100644 webapp/app/pods/logged-in/project/versions/export/controller.js create mode 100644 webapp/app/pods/logged-in/project/versions/export/route.js create mode 100644 webapp/app/pods/logged-in/project/versions/export/template.hbs create mode 100644 webapp/app/pods/logged-in/project/versions/new/controller.js create mode 100644 webapp/app/pods/logged-in/project/versions/new/route.js create mode 100644 webapp/app/pods/logged-in/project/versions/new/template.hbs create mode 100644 webapp/app/pods/logged-in/project/versions/route.js create mode 100644 webapp/app/pods/logged-in/project/versions/template.hbs create mode 100644 webapp/app/pods/logged-in/projects/controller.js create mode 100644 webapp/app/pods/logged-in/projects/new/controller.js create mode 100644 webapp/app/pods/logged-in/projects/new/route.js create mode 100644 webapp/app/pods/logged-in/projects/new/template.hbs create mode 100644 webapp/app/pods/logged-in/projects/route.js create mode 100644 webapp/app/pods/logged-in/projects/template.hbs create mode 100644 webapp/app/pods/logged-in/route.js create mode 100644 webapp/app/pods/logged-in/template.hbs create mode 100644 webapp/app/pods/login/controller.js create mode 100644 webapp/app/pods/login/route.js create mode 100644 webapp/app/pods/login/template.hbs create mode 100644 webapp/app/pods/not-found/controller.js create mode 100644 webapp/app/pods/not-found/template.hbs create mode 100644 webapp/app/pods/phoenix/service.js create mode 100644 webapp/app/queries/activity-activities.graphql create mode 100644 webapp/app/queries/conflicts.graphql create mode 100644 webapp/app/queries/correct-all-revision.graphql create mode 100644 webapp/app/queries/correct-translation.graphql create mode 100644 webapp/app/queries/create-collaborator.graphql create mode 100644 webapp/app/queries/create-comment.graphql create mode 100644 webapp/app/queries/create-integration.graphql create mode 100644 webapp/app/queries/create-project.graphql create mode 100644 webapp/app/queries/create-revision.graphql create mode 100644 webapp/app/queries/create-translation-comments-subscription.graphql create mode 100644 webapp/app/queries/create-version.graphql create mode 100644 webapp/app/queries/delete-collaborator.graphql create mode 100644 webapp/app/queries/delete-document.graphql create mode 100644 webapp/app/queries/delete-integration.graphql create mode 100644 webapp/app/queries/delete-project.graphql create mode 100644 webapp/app/queries/delete-revision.graphql create mode 100644 webapp/app/queries/delete-translation-comments-subscription.graphql create mode 100644 webapp/app/queries/languages-search.graphql create mode 100644 webapp/app/queries/project-activities.graphql create mode 100644 webapp/app/queries/project-activity.graphql create mode 100644 webapp/app/queries/project-api-token.graphql create mode 100644 webapp/app/queries/project-collaborators.graphql create mode 100644 webapp/app/queries/project-comments.graphql create mode 100644 webapp/app/queries/project-dashboard.graphql create mode 100644 webapp/app/queries/project-documents.graphql create mode 100644 webapp/app/queries/project-edit.graphql create mode 100644 webapp/app/queries/project-new-language.graphql create mode 100644 webapp/app/queries/project-service-integrations.graphql create mode 100644 webapp/app/queries/project-versions.graphql create mode 100644 webapp/app/queries/project.graphql create mode 100644 webapp/app/queries/projects.graphql create mode 100644 webapp/app/queries/promote-master-revision.graphql create mode 100644 webapp/app/queries/related-translations.graphql create mode 100644 webapp/app/queries/rollback-operation.graphql create mode 100644 webapp/app/queries/translation-activities.graphql create mode 100644 webapp/app/queries/translation-comments.graphql create mode 100644 webapp/app/queries/translation.graphql create mode 100644 webapp/app/queries/translations.graphql create mode 100644 webapp/app/queries/uncorrect-all-revision.graphql create mode 100644 webapp/app/queries/uncorrect-translation.graphql create mode 100644 webapp/app/queries/update-collaborator.graphql create mode 100644 webapp/app/queries/update-integration.graphql create mode 100644 webapp/app/queries/update-project.graphql create mode 100644 webapp/app/queries/update-translation.graphql create mode 100644 webapp/app/queries/update-version.graphql create mode 100644 webapp/app/resolver.js create mode 100644 webapp/app/router.js create mode 100644 webapp/app/services/apollo-mutate.js create mode 100644 webapp/app/services/apollo.js create mode 100644 webapp/app/services/authenticated-request.js create mode 100644 webapp/app/services/exporter.js create mode 100644 webapp/app/services/file-saver.js create mode 100644 webapp/app/services/global-state.js create mode 100644 webapp/app/services/language-searcher.js create mode 100644 webapp/app/services/merger.js create mode 100644 webapp/app/services/peeker.js create mode 100644 webapp/app/services/raven.js create mode 100644 webapp/app/services/session.js create mode 100644 webapp/app/services/session/creator.js create mode 100644 webapp/app/services/session/destroyer.js create mode 100644 webapp/app/services/session/fetcher.js create mode 100644 webapp/app/services/session/persister.js create mode 100644 webapp/app/services/syncer.js create mode 100644 webapp/app/styles/app.scss create mode 100644 webapp/app/styles/base.scss create mode 100644 webapp/app/styles/classes.scss create mode 100644 webapp/app/styles/html-components/filters.scss create mode 100644 webapp/app/styles/html-components/form/button.scss create mode 100644 webapp/app/styles/html-components/form/label.scss create mode 100644 webapp/app/styles/html-components/power-select.scss create mode 100644 webapp/app/styles/html-components/sub-navigation.scss create mode 100644 webapp/app/styles/html-components/wrapper.scss create mode 100644 webapp/app/styles/modal.scss create mode 100644 webapp/app/styles/reset-select.scss create mode 100644 webapp/app/styles/reset.css create mode 100644 webapp/app/styles/variables/colors.scss create mode 100644 webapp/app/styles/variables/dimensions.scss create mode 100644 webapp/app/styles/variables/fonts.scss create mode 100644 webapp/app/styles/variables/power-select.scss create mode 100644 webapp/app/styles/variables/transitions.scss create mode 100644 webapp/app/utils/phoenix.js create mode 100644 webapp/config/environment.js create mode 100644 webapp/ember-cli-build.js create mode 100644 webapp/package-lock.json create mode 100644 webapp/package.json create mode 100644 webapp/public/assets/activity.svg create mode 100644 webapp/public/assets/add.svg create mode 100644 webapp/public/assets/badge.svg create mode 100644 webapp/public/assets/bot.svg create mode 100644 webapp/public/assets/bubble.svg create mode 100644 webapp/public/assets/burger.svg create mode 100644 webapp/public/assets/check.svg create mode 100644 webapp/public/assets/chevron-left.svg create mode 100644 webapp/public/assets/chevron-right.svg create mode 100644 webapp/public/assets/chevron-top.svg create mode 100644 webapp/public/assets/empty.svg create mode 100644 webapp/public/assets/export.svg create mode 100644 webapp/public/assets/eye.svg create mode 100644 webapp/public/assets/favicon.png create mode 100644 webapp/public/assets/file.svg create mode 100644 webapp/public/assets/fullscreen-minimize.svg create mode 100644 webapp/public/assets/fullscreen.svg create mode 100644 webapp/public/assets/gear.svg create mode 100644 webapp/public/assets/google-logo.png create mode 100644 webapp/public/assets/home.svg create mode 100644 webapp/public/assets/language.svg create mode 100644 webapp/public/assets/loading.svg create mode 100644 webapp/public/assets/lock--locked.svg create mode 100644 webapp/public/assets/lock--unlocked.svg create mode 100644 webapp/public/assets/logo-bw.svg create mode 100644 webapp/public/assets/logo.svg create mode 100644 webapp/public/assets/merge.svg create mode 100644 webapp/public/assets/pencil.svg create mode 100644 webapp/public/assets/reload.svg create mode 100644 webapp/public/assets/revert.svg create mode 100644 webapp/public/assets/search.svg create mode 100644 webapp/public/assets/services/slack.svg create mode 100644 webapp/public/assets/share.svg create mode 100644 webapp/public/assets/sync.svg create mode 100644 webapp/public/assets/tag.svg create mode 100644 webapp/public/assets/thumbs-up.svg create mode 100644 webapp/public/assets/users.svg create mode 100644 webapp/public/assets/x.svg create mode 100644 webapp/public/crossdomain.xml create mode 100644 webapp/public/favicon.png create mode 100644 webapp/public/robots.txt create mode 100644 webapp/testem.js create mode 100644 webapp/tests/.jshintrc create mode 100644 webapp/tests/acceptance/user-redirection-with-auth-status-test.js create mode 100644 webapp/tests/helpers/destroy-app.js create mode 100644 webapp/tests/helpers/flash-message.js create mode 100644 webapp/tests/helpers/module-for-acceptance.js create mode 100644 webapp/tests/helpers/resolver.js create mode 100644 webapp/tests/helpers/start-app.js create mode 100644 webapp/tests/index.html create mode 100644 webapp/tests/test-helper.js create mode 100644 webapp/tests/unit/services/session/creator-test.js create mode 100644 webapp/tests/unit/services/session/destroyer-test.js create mode 100644 webapp/tests/unit/services/session/fetcher-test.js create mode 100644 webapp/tests/unit/services/session/persister-test.js diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 00000000..5ab4e8d1 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,65 @@ +%{ + configs: [ + %{ + name: "default", + strict: true, + files: %{ + included: ["lib/", "test/", "priv/"], + excluded: [] + }, + checks: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + {Credo.Check.Design.AliasUsage, if_called_more_often_than: 2, if_nested_deeper_than: 1}, + {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, + {Credo.Check.Design.TagTODO}, + {Credo.Check.Design.TagFIXME}, + + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, max_length: 200}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + + {Credo.Check.Refactor.ABCSize}, + {Credo.Check.Refactor.CaseTrivialMatches}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.PipeChainStart, + excluded_argument_types: ~w(atom binary fn keyword)a, + excluded_functions: ~w(from)}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.UnlessWithElse}, + + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect, false}, + {Credo.Check.Warning.NameRedeclarationByAssignment}, + {Credo.Check.Warning.NameRedeclarationByCase}, + {Credo.Check.Warning.NameRedeclarationByDef}, + {Credo.Check.Warning.NameRedeclarationByFn}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + {Credo.Check.Warning.OperationWithConstantResult}, + ] + } + ] +} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 00000000..418d132e --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 180 +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9d43e6b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# App artifacts +/_build +/db +/tmp +/doc +/deps +/*.ez +/cover + +# Generate on crash by the VM +erl_crash.dump + +# The config/prod.secret.exs file by default contains sensitive +# data and you should not commit it into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets file as long as you replace its contents by environment +# variables. +/config/prod.secret.exs + +test.json + +.env* + +priv/static/webapp +/webapp/node_modules diff --git a/.hanzo.yml b/.hanzo.yml new file mode 100644 index 00000000..9372aa9f --- /dev/null +++ b/.hanzo.yml @@ -0,0 +1,5 @@ +remotes: + qa: mirego-accent-api-v2-qa + production: mirego-accent-api-v2-prod +after_deploy: + - mix ecto.migrate diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..fa981459 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,43 @@ +# Use Travis container-based infrastructure +sudo: false + +# Elixir baby! +language: 'elixir' + +elixir: 1.6.4 +otp_release: 20.2.1 +node_js: 9.5.0 + +cache: + directories: + - _build + - deps + +# Make sure PostgreSQL is running +addons: + postgresql: 9.6 + apt: + packages: + - libyaml-dev + +# Set global environment variables +env: + global: + - NODE_VERSION: '9.5.0' + - MIX_ENV: 'test' + - SECRET_KEY_BASE: 'lolwut' + - DATABASE_URL: 'postgres://localhost/accent_web_test' + +# Output Travis server IP for debugging +before_install: + - 'echo `curl --verbose http://jsonip.com`' + - npm config set spin false + - npm --prefix webapp install + +# Create database and prepare the application +before_script: + - mix compile + - mix ecto.setup + +script: + - ./priv/scripts/ci-check.sh diff --git a/Aptfile b/Aptfile new file mode 100644 index 00000000..9a0b0644 --- /dev/null +++ b/Aptfile @@ -0,0 +1 @@ +https://s3.amazonaws.com/shared.ws.mirego.com/libyaml-dev_0.1.4-3ubuntu3-2.1_amd64.deb diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..79daf1b6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,38 @@ +# Code of Conduct + +Contact: info@mirego.com + +## Why have a Code of Conduct? + +As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. + +The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about Accent effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. + +## Our Values + +These are the values Accent developers should aspire to: + + * Be friendly and welcoming + * Be patient + * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) + * Be thoughtful + * Productive communication requires effort. Think about how your words will be interpreted. + * Remember that sometimes it is best to refrain entirely from commenting. + * Be respectful + * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. + * Avoid destructive behavior + * Derailing: stay on topic; if you want to talk about something else, start a new conversation. + * Unconstructive criticism: don't merely decry the current state of affairs; offer (or at least solicit) suggestions as to how things may be improved. + * Snarking (pithy, unproductive, sniping comments). + +The following actions are explicitly forbidden: + + * Insulting, demeaning, hateful, or threatening remarks. + * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. + * Bullying or systematic harassment. + * Unwelcome sexual advances. + * Incitement to any of these. + +## Acknowledgements + +This document was based on the Code of Conduct from the Elixir project. diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..db27e526 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +group :development do + gem 'hanzo' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..4ea4013b --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,15 @@ +GEM + remote: https://rubygems.org/ + specs: + hanzo (1.0.2) + highline (>= 1.6.19) + highline (1.7.10) + +PLATFORMS + ruby + +DEPENDENCIES + hanzo + +BUNDLED WITH + 1.15.4 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a64b5852 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2018, Mirego All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + Neither the name of the Mirego nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..23f20977 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: mix phoenix.server diff --git a/README.md b/README.md new file mode 100644 index 00000000..4253041e --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ + + +[Website](https://www.accent.reviews) • [GraphiQL](https://www.accent.reviews/documentation) + +[![Build Status](https://travis-ci.com/mirego/accent-web-v2.svg?token=ySqXG5pmHqKKGyP2ECxE&branch=master)](https://travis-ci.com/mirego/accent-web-v2) + +**The first developer oriented translation tool**. Accent’s engine coupled with the asynchronous flow between the translator and the developer is what makes Accent the most awesome tool of all. + +The Accent API provides a powerful abstraction around the process of translating and maintaing the translations of an app. + +* **Collaboration**. Centralize your discussions around translations. +* **History**. Full history control and actions rollback. _Who_ did _what_, _when_. +* **UI**. Simple yet powerful UI to enable translator and developer to be productive. +* **In-Context Editor**. Pluggable with any frontend/backend frameworks. +* **GraphQL**. The API that powers the UI is open and documented. It’s easy to build plugin/cli/librairy around Accent. + +_See the webapp to deploy your own Accent stack: [GitHub Repo](https://github.com/mirego/accent-webapp-v2)_ + +## Contents + +* [Requirements](#requirements) +* [Mix commands](#executing-mix-commands) +* [Quickstart](#quickstart) +* [Environment variables](#environment-variables) +* [Tests](#tests) +* [Heroku](#deploy-on-heroku) +* [Contribute](#contribute) + +## Requirements + +- Erlang OTP 20.1 +- Elixir 1.6.2 +- PostgreSQL >= 9.4 +- Node.js >= 8.5.0 +- libyaml + +## Executing mix commands + +The app is modeled with the _Twelve-Factor_ architecture, all configurations are stored in the environment. + +When executing mix command, you should always make sure that the required system `ENV` are present. You can `source`, use [nv](https://github.com/jcouture/nv) or a custom l33t bash script. + +Every following steps assume you have this kind of system. +But Accent can be run with default env var if you have a PostgreSQL user named postgres listening on port 5432 on localhost. + +### Example + +With `nv` you inject the environment keys in the context with: + +```shell +> nv .env mix + +``` + +## Quickstart + + 1. If you don’t already have it, install `nodejs` with `brew install nodejs` + 1. If you don’t already have it, install `elixir` with `brew install elixir` + 2. If you don’t already have it, install `libyaml` with `brew install libyaml` + 2. If you don’t already have it, install `PostgreSQL` with `brew install postgres` or the [macOS app](https://postgresapp.com/) + 3. Install dependencies with `mix deps.get` and `npm --prefix webapp install`. + 4. Create and migrate your database with `mix ecto.setup` + 5. Start Phoenix endpoint with `mix phx.server` + 5. Start Ember server with `npm --prefix webapp run start` + 6. That’s it. + +## Environment variables + +This app provides default value for every env var. This means that with the right PostgreSQL setup, you can just run `mix phx.server`. + +- `DATABASE_URL=postgres://localhost/accent_development`: A valid database url. Like the one used by Heroku. +- `PORT=4000`: A PORT to run your app. +- `WEBAPP_PORT=4200`: A PORT to run your webapp. (only used in dev) +- `API_HOST=http://localhost:4000`: The host of the API. +- `API_WS_HOST=ws://localhost:4000`: The websocket host of the API. +- `MIX_ENV=dev` : Environment to run mix {dev, prod, test} +- `WEBAPP_EMAIL_HOST=localhost:8001`: Web client’s hostname. Used in the sent emails to link to the right URL. There is no default value, please provide a value if you want to send emails. +- `MAILER_FROM=anEmail@gmail.com`: Email address used in the sent email. There is no default value, please provide a value if you want to send emails. + +### Production setup + +- `SENTRY_DSN` +- `WEBAPP_SENTRY_DSN` +- `GOOGLE_API_CLIENT_ID`: When deploying in a production env, the Google login is the only way to authenticate user. In dev, a fake login provider is used so you don’t have to setup a Google app. + +## Tests + +### API + +This app provides default value for every env var required in test. This means that with the right PostgreSQL setup, you can just run `mix test`. + +- `mix test` + +## Deploy on Heroku + +To successfully deploy the application on Heroku, you must use these buildpacks: + +_The first buildpack is to use the Aptfile to install libyaml._ + +```shell +$ heroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-apt#usr-local-paths +$ heroku buildpacks:add --index 2 https://github.com/HashNuke/heroku-buildpack-elixir +$ heroku buildpacks:add --index 3 https://github.com/gjaldon/heroku-buildpack-phoenix-static +``` + +## Contribute + +Before opening a pull request, please open an issue first. + +```shell +$ git clone https://github.com/mirego/accent-web-v2.git +$ cd accent-web-v2 +$ mix deps.get +$ mix test +``` + +Once you've made your additions and the test suite passes, go ahead and open a PR! +Don’t forget to run the `./priv/scripts/ci-check.sh` script to make sure that the CI will pass :) + +## About Mirego + +[Mirego](http://mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We're a team of [talented people](http://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](http://mirego.org). + +We also [love open-source software](http://open.mirego.com) and we try to give back to the community as much as we can. diff --git a/compile b/compile new file mode 100644 index 00000000..3d8bd7fa --- /dev/null +++ b/compile @@ -0,0 +1,2 @@ +cd $phoenix_dir +npm --prefix ./webapp run build-production diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 00000000..af91cd92 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,67 @@ +use Mix.Config + +defmodule Utilities do + def string_to_boolean("true"), do: true + def string_to_boolean("1"), do: true + def string_to_boolean(_), do: false +end + +# Configures the endpoint +config :accent, Accent.Endpoint, + root: Path.expand("..", __DIR__), + http: [port: System.get_env("PORT")], + url: [host: System.get_env("CANONICAL_HOST") || "localhost"], + secret_key_base: System.get_env("SECRET_KEY_BASE"), + render_errors: [accepts: ~w(json)], + pubsub: [name: Accent.PubSub, adapter: Phoenix.PubSub.PG2] + +# Configure your database +config :accent, :ecto_repos, [Accent.Repo] + +config :accent, Accent.Repo, + adapter: Ecto.Adapters.Postgres, + timeout: 30000, + url: System.get_env("DATABASE_URL") || "postgres://localhost/accent_development" + +config :phoenix, :format_encoders, "json-api": Poison +config :phoenix, Accent.Router, host: System.get_env("CANONICAL_HOST") + +config :accent, force_ssl: Utilities.string_to_boolean(System.get_env("FORCE_SSL")) + +config :accent, hook_broadcaster: Accent.Hook.Broadcaster + +config :accent, dummy_provider_enabled: true + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :canary, + repo: Accent.Repo, + unauthorized_handler: {Accent.ErrorController, :handle_unauthorized}, + not_found_handler: {Accent.ErrorController, :handle_not_found} + +config :sentry, + dsn: System.get_env("SENTRY_DSN"), + included_environments: [:prod], + environment_name: Mix.env(), + root_source_code_path: File.cwd!() + +# Used to extract schema json with the absinthe’s mix task +config :absinthe, :schema, Accent.GraphQL.Schema + +# Configure phoenix generators +config :phoenix, :generators, + migration: true, + binary_id: false + +config :mime, :types, %{ + "application/vnd.api+json" => ["json-api"] +} + +import_config "mailer.exs" + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 00000000..d3727b32 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,22 @@ +use Mix.Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with brunch.io to recompile .js and .css sources. +config :accent, Accent.Endpoint, + debug_errors: true, + code_reloader: true, + cache_static_lookup: false, + check_origin: false, + watchers: [] + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. +# Do not configure such in production as keeping +# and calculating stacktraces is usually expensive. +config :phoenix, :stacktrace_depth, 20 diff --git a/config/mailer.exs b/config/mailer.exs new file mode 100644 index 00000000..dc4ecb54 --- /dev/null +++ b/config/mailer.exs @@ -0,0 +1,18 @@ +use Mix.Config + +if System.get_env("SMTP_ADDRESS") do + config :accent, Accent.Mailer, + webapp_host: System.get_env("WEBAPP_EMAIL_HOST"), + mailer_from: System.get_env("MAILER_FROM"), + adapter: Bamboo.SMTPAdapter, + server: System.get_env("SMTP_ADDRESS"), + port: System.get_env("SMTP_PORT"), + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + x_smtpapi_header: System.get_env("SMTP_API_HEADER") +else + config :accent, Accent.Mailer, + webapp_host: System.get_env("WEBAPP_EMAIL_HOST"), + mailer_from: System.get_env("MAILER_FROM"), + adapter: Bamboo.LocalAdapter +end diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 00000000..645419a9 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,6 @@ +use Mix.Config + +config :accent, Accent.Endpoint, check_origin: false +config :accent, dummy_provider_enabled: false + +config :logger, level: :info diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 00000000..be94044f --- /dev/null +++ b/config/test.exs @@ -0,0 +1,26 @@ +use Mix.Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :accent, Accent.Endpoint, + http: [port: 4001], + server: false + +# Print only warnings and errors during test +config :logger, level: :warn + +# Configure your database +config :accent, sql_sandbox: true + +config :accent, Accent.Repo, + adapter: Ecto.Adapters.Postgres, + url: System.get_env("DATABASE_URL") || "postgres://localhost/accent_test", + pool: Ecto.Adapters.SQL.Sandbox + +config :accent, Accent.Mailer, + webapp_host: "http://example.com", + mailer_from: "accent-test@example.com", + x_smtpapi_header: ~s({"category": ["test", "accent-api-test"]}), + adapter: Bamboo.LocalAdapter + +config :accent, hook_broadcaster: Accent.Hook.BroadcasterMock diff --git a/elixir_buildpack.config b/elixir_buildpack.config new file mode 100644 index 00000000..62b79c76 --- /dev/null +++ b/elixir_buildpack.config @@ -0,0 +1,11 @@ +# Erlang version +erlang_version=20.2.1 + +# Elixir version +elixir_version=1.6.4 + +# Always rebuild from scratch on every deploy? +always_rebuild=false + +# Export heroku config vars +config_vars_to_export=(PORT DATABASE_URL SECRET_KEY_BASE CANONICAL_HOST) diff --git a/lib/accent.ex b/lib/accent.ex new file mode 100644 index 00000000..8a0c260e --- /dev/null +++ b/lib/accent.ex @@ -0,0 +1,36 @@ +defmodule Accent do + use Application + + # See http://elixir-lang.org/docs/stable/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec, warn: false + + children = [ + # Start the endpoint when the application starts + supervisor(Accent.Endpoint, []), + # Start the Ecto repository + worker(Accent.Repo, []), + worker(Accent.Hook.Producers.Email, []), + worker(Accent.Hook.Consumers.Email, []), + worker(Accent.Hook.Producers.Websocket, []), + worker(Accent.Hook.Consumers.Websocket, []), + worker(Accent.Hook.Producers.Slack, []), + worker(Accent.Hook.Consumers.Slack, http_client: HTTPoison) + ] + + :ok = :error_logger.add_report_handler(Sentry.Logger) + + # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Accent.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + Accent.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/accent/auth/canada_implementations.ex b/lib/accent/auth/canada_implementations.ex new file mode 100644 index 00000000..502bff61 --- /dev/null +++ b/lib/accent/auth/canada_implementations.ex @@ -0,0 +1,23 @@ +defimpl Canada.Can, for: Accent.User do + alias Accent.{User, Project, Revision} + + def can?(_user, _action, nil), do: false + + def can?(%User{permissions: permissions}, action, project_id) when is_binary(project_id) do + validate_role(permissions, action, project_id) + end + + def can?(%User{permissions: permissions}, action, %Project{id: project_id}) when is_binary(project_id) do + validate_role(permissions, action, project_id) + end + + def can?(%User{permissions: permissions}, action, %Revision{project_id: project_id}) when is_binary(project_id) do + validate_role(permissions, action, project_id) + end + + def validate_role(permissions, action, project_id) do + permissions + |> Map.get(project_id) + |> Accent.RoleAbilities.can?(action) + end +end diff --git a/lib/accent/auth/role_abilities.ex b/lib/accent/auth/role_abilities.ex new file mode 100644 index 00000000..74ce0e2b --- /dev/null +++ b/lib/accent/auth/role_abilities.ex @@ -0,0 +1,99 @@ +defmodule Accent.RoleAbilities do + @owner_role "owner" + @admin_role "admin" + @bot_role "bot" + @developer_role "developer" + @reviewer_role "reviewer" + + @any_actions ~w( + index_permissions + index_versions + create_version + update_version + export_version + index_translations + index_comments + create_comment + show_comment + correct_all_revision + uncorrect_all_revision + show_project + index_collaborators + correct_translation + uncorrect_translation + update_translation + show_translation + index_project_activities + index_related_translations + index_revisions + show_revision + index_documents + show_document + show_activity + index_translation_activities + index_translation_comments_subscriptions + create_translation_comments_subscription + delete_translation_comments_subscription + )a + + @bot_actions ~w( + peek_sync + peek_merge + merge + sync + )a ++ @any_actions + + @developer_actions ~w( + peek_sync + peek_merge + merge + sync + delete_document + show_project_access_token + index_integrations + create_integration + update_integration + delete_integration + )a ++ @any_actions + + @admin_actions ~w( + create_slave + delete_slave + promote_slave + update_project + delete_collaborator + create_collaborator + update_collaborator + rollback + lock_project_file_operations + delete_project + )a ++ @developer_actions + + def actions_for(@owner_role), do: @admin_actions + def actions_for(@admin_role), do: @admin_actions + def actions_for(@bot_role), do: @bot_actions + def actions_for(@developer_role), do: @developer_actions + def actions_for(@reviewer_role), do: @any_actions + + # Define abilities function at compile time to remove list lookup at runtime + def can?(@owner_role, _action), do: true + + for action <- @admin_actions do + def can?(@admin_role, unquote(action)), do: true + end + + for action <- @bot_actions do + def can?(@bot_role, unquote(action)), do: true + end + + for action <- @developer_actions do + def can?(@developer_role, unquote(action)), do: true + end + + for action <- @any_actions do + def can?(@reviewer_role, unquote(action)), do: true + end + + # Fallback if no permission has been found for the user on the project + def can?(_role, _action), do: false +end diff --git a/lib/accent/auth/user_auth_fetcher.ex b/lib/accent/auth/user_auth_fetcher.ex new file mode 100644 index 00000000..64f51017 --- /dev/null +++ b/lib/accent/auth/user_auth_fetcher.ex @@ -0,0 +1,49 @@ +defmodule Accent.UserAuthFetcher do + import Ecto.Query, only: [from: 2] + + alias Accent.{ + Repo, + Collaborator, + User + } + + @doc """ + fetch the associated user. It also fetches the permissions + """ + @spec fetch(String.t() | nil) :: User.t() | nil + def fetch(access_token) do + access_token + |> fetch_user + |> map_permissions + end + + defp fetch_user("Bearer " <> token) when is_binary(token) do + from( + user in User, + left_join: access_token in assoc(user, :access_tokens), + where: access_token.token == ^token, + where: is_nil(access_token.revoked_at) + ) + |> Repo.one() + end + + defp fetch_user(_any), do: nil + + defp map_permissions(nil), do: nil + + defp map_permissions(user) do + permissions = + from( + collaborator in Collaborator, + where: [user_id: ^user.id], + select: %{project_id: collaborator.project_id, role: collaborator.role} + ) + |> Repo.all() + |> Enum.reduce(Map.new(), fn %{project_id: project_id, role: role}, acc -> + Map.put(acc, project_id, role) + end) + + user + |> Map.put(:permissions, permissions) + end +end diff --git a/lib/accent/auth/user_remote/adapter/fetcher.ex b/lib/accent/auth/user_remote/adapter/fetcher.ex new file mode 100644 index 00000000..21845011 --- /dev/null +++ b/lib/accent/auth/user_remote/adapter/fetcher.ex @@ -0,0 +1,5 @@ +defmodule Accent.UserRemote.Adapter.Fetcher do + alias Accent.UserRemote.Adapter.User + + @callback fetch(String.t()) :: {:ok, User.t()} | {:error, list(String.t())} +end diff --git a/lib/accent/auth/user_remote/adapter/user.ex b/lib/accent/auth/user_remote/adapter/user.ex new file mode 100644 index 00000000..174251a8 --- /dev/null +++ b/lib/accent/auth/user_remote/adapter/user.ex @@ -0,0 +1,5 @@ +defmodule Accent.UserRemote.Adapter.User do + defstruct ~w(email provider uid fullname picture_url)a + + @type t :: %__MODULE__{email: String.t(), provider: String.t(), uid: String.t(), fullname: String.t(), picture_url: String.t()} +end diff --git a/lib/accent/auth/user_remote/adapters/dummy.ex b/lib/accent/auth/user_remote/adapters/dummy.ex new file mode 100644 index 00000000..cff90fcb --- /dev/null +++ b/lib/accent/auth/user_remote/adapters/dummy.ex @@ -0,0 +1,17 @@ +if Application.get_env(:accent, :dummy_provider_enabled) do + defmodule Accent.UserRemote.Adapters.Dummy do + @moduledoc """ + This is the simplest adapter for user remote fetching. + + It simply returns the value as both the email and the uid. + """ + + @behaviour Accent.UserRemote.Adapter.Fetcher + @name "dummy" + + alias Accent.UserRemote.Adapter.User + + def fetch(value) when value === "", do: {:error, ["invalid email"]} + def fetch(value), do: {:ok, %User{email: String.downcase(value), provider: @name, uid: value}} + end +end diff --git a/lib/accent/auth/user_remote/adapters/google.ex b/lib/accent/auth/user_remote/adapters/google.ex new file mode 100644 index 00000000..8a6f5c4f --- /dev/null +++ b/lib/accent/auth/user_remote/adapters/google.ex @@ -0,0 +1,39 @@ +defmodule Accent.UserRemote.Adapters.Google do + @moduledoc """ + Fetches the email and the uid from the id_token + using the Google API v3 token info endpoint. + """ + + @behaviour Accent.UserRemote.Adapter.Fetcher + @name "google" + + alias Accent.UserRemote.Adapter.User + + defmodule TokenInfoClient do + use HTTPoison.Base + + @base_url "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" + + def process_url(token), do: @base_url <> token + + def process_response_body(body), do: Poison.decode!(body) + end + + def fetch(token) when token === "", do: {:error, "invalid token"} + + def fetch(token) do + token + |> TokenInfoClient.get() + |> case do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + email = String.downcase(body["email"]) + {:ok, %User{provider: @name, fullname: body["name"], picture_url: body["picture"], email: email, uid: email}} + + {:ok, %HTTPoison.Response{status_code: 400}} -> + {:error, "invalid token"} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} + end + end +end diff --git a/lib/accent/auth/user_remote/authenticator.ex b/lib/accent/auth/user_remote/authenticator.ex new file mode 100644 index 00000000..0dbf7124 --- /dev/null +++ b/lib/accent/auth/user_remote/authenticator.ex @@ -0,0 +1,27 @@ +defmodule Accent.UserRemote.Authenticator do + alias Accent.UserRemote.{Fetcher, Persister, TokenGiver, CollaboratorNormalizer} + + def authenticate(provider, uid) do + provider + |> fetch(uid) + |> persist + |> normalize_collaborators + |> grant_token + end + + defp fetch(provider, uid), do: Fetcher.fetch(provider, uid) + + defp persist({:error, error}), do: {:error, error} + defp persist({:ok, user}), do: Persister.persist(user) + + defp normalize_collaborators({:error, error}), do: {:error, error} + + defp normalize_collaborators({:ok, user, provider}) do + CollaboratorNormalizer.normalize(user) + + {:ok, user, provider} + end + + defp grant_token({:error, error}), do: {:error, error} + defp grant_token({:ok, user, _provider}), do: TokenGiver.grant_token(user) +end diff --git a/lib/accent/auth/user_remote/collaborator_normalizer.ex b/lib/accent/auth/user_remote/collaborator_normalizer.ex new file mode 100644 index 00000000..efa15753 --- /dev/null +++ b/lib/accent/auth/user_remote/collaborator_normalizer.ex @@ -0,0 +1,28 @@ +defmodule Accent.UserRemote.CollaboratorNormalizer do + import Ecto.Query, only: [from: 2] + + alias Accent.{Repo, User, Collaborator} + + @spec normalize(User.t()) :: :ok + def normalize(%User{id: id, email: email}) do + email + |> fetch_collaborators() + |> assign_user_id(id) + |> Enum.each(&Repo.update/1) + + :ok + end + + defp fetch_collaborators(email) do + email = String.downcase(email) + + from(c in Collaborator, where: [email: ^email]) + |> Repo.all() + end + + defp assign_user_id(collaborators, user_id) do + Enum.map(collaborators, fn collaborator -> + Collaborator.create_changeset(collaborator, %{"user_id" => user_id}) + end) + end +end diff --git a/lib/accent/auth/user_remote/fetcher.ex b/lib/accent/auth/user_remote/fetcher.ex new file mode 100644 index 00000000..caa9de49 --- /dev/null +++ b/lib/accent/auth/user_remote/fetcher.ex @@ -0,0 +1,17 @@ +defmodule Accent.UserRemote.Fetcher do + @moduledoc """ + Fetch a user (email and provider infos) based on a single value. + """ + + alias Accent.UserRemote.Adapters.Google + + def fetch(_provider, ""), do: {:error, %{value: "empty"}} + def fetch(_provider, nil), do: {:error, %{value: "empty"}} + + if Application.get_env(:accent, :dummy_provider_enabled) do + def fetch("dummy", value), do: Accent.UserRemote.Adapters.Dummy.fetch(value) + end + + def fetch("google", value), do: Google.fetch(value) + def fetch(_provider, _value), do: {:error, %{provider: "unknown"}} +end diff --git a/lib/accent/auth/user_remote/persister.ex b/lib/accent/auth/user_remote/persister.ex new file mode 100644 index 00000000..1c65e463 --- /dev/null +++ b/lib/accent/auth/user_remote/persister.ex @@ -0,0 +1,54 @@ +defmodule Accent.UserRemote.Persister do + @moduledoc """ + Manage user creation and provider creation. + + This module makes sure that a user, returned from the Accent.UserRemote.Fetcher, + is persisted in the database with its provider infos and email. + + 3 cases can happen when a user is fetched. + + - New user with new provider. (First time logging in) + - Existing user with same provider. (Same login as the first time) + - Existing user but with a different provider. (Login with a different provider) + + """ + alias Accent.Repo + alias Accent.AuthProvider + alias Accent.User, as: RepoUser + alias Accent.UserRemote.Adapter.User, as: FetchedUser + alias Ecto.Changeset + + @spec persist(FetchedUser.t()) :: {:ok, RepoUser.t(), AuthProvider.t()} + def persist(user = %FetchedUser{provider: provider, uid: uid}) do + user = find_user(user) + provider = find_provider(user, provider, uid) + + {:ok, user, provider} + end + + defp find_user(fetched_user) do + case Repo.get_by(RepoUser, email: fetched_user.email) do + user = %RepoUser{} -> update_user(user, fetched_user) + _ -> create_user(fetched_user) + end + end + + defp find_provider(user, provider_name, uid) do + case Repo.get_by(AuthProvider, name: provider_name, uid: uid) do + provider = %AuthProvider{} -> provider + _ -> create_provider(user, provider_name, uid) + end + end + + defp create_provider(user, name, uid), do: Repo.insert!(%AuthProvider{name: name, uid: uid, user_id: user.id}) + defp create_user(fetched_user), do: Repo.insert!(%RepoUser{email: fetched_user.email, fullname: fetched_user.fullname, picture_url: fetched_user.picture_url}) + + defp update_user(user, fetched_user) do + user + |> Changeset.change(%{ + fullname: fetched_user.fullname || user.fullname, + picture_url: fetched_user.picture_url || user.picture_url + }) + |> Repo.update!() + end +end diff --git a/lib/accent/auth/user_remote/token_giver.ex b/lib/accent/auth/user_remote/token_giver.ex new file mode 100644 index 00000000..71f08fa6 --- /dev/null +++ b/lib/accent/auth/user_remote/token_giver.ex @@ -0,0 +1,25 @@ +defmodule Accent.UserRemote.TokenGiver do + alias Accent.Repo + alias Accent.Utils.SecureRandom + + def grant_token(user) do + invalidate_tokens(user) + + token = create_token(user) + + {:ok, user, token} + end + + defp invalidate_tokens(user) do + user + |> Ecto.assoc(:access_tokens) + |> Repo.update_all(set: [revoked_at: NaiveDateTime.utc_now()]) + end + + defp create_token(user) do + user + |> Ecto.build_assoc(:access_tokens) + |> Map.put(:token, SecureRandom.urlsafe_base64(70)) + |> Repo.insert!() + end +end diff --git a/lib/accent/badge_generator.ex b/lib/accent/badge_generator.ex new file mode 100644 index 00000000..8a713819 --- /dev/null +++ b/lib/accent/badge_generator.ex @@ -0,0 +1,67 @@ +defmodule Accent.BadgeGenerator do + alias Accent.{ + Revision, + PrettyFloat, + TranslationsCounter + } + + @badge_service_timeout 20_000 + @base_badge_service_url "https://img.shields.io/badge/" + + def generate(project, attribute) do + project_stats = + project.revisions + |> merge_revisions_stats() + |> merge_project_stats() + + color = color_for_value(project_stats[attribute], attribute) + + (@base_badge_service_url <> "accent-#{label(project_stats[attribute], attribute)}-#{color}.svg") + |> HTTPoison.get([], recv_timeout: @badge_service_timeout) + |> case do + {:ok, %{body: body}} -> {:ok, body} + _ -> {:error, "internal error"} + end + end + + defp color_for_value(value, :percentage_reviewed_count) when value < 50, do: "d84444" + defp color_for_value(value, :percentage_reviewed_count) when value <= 75, do: "e4b600" + defp color_for_value(_value, :percentage_reviewed_count), do: "45c86f" + defp color_for_value(_value, _), do: "aaaaaa" + + defp label(value, :percentage_reviewed_count), do: "#{value}%25" + defp label(value, :translations_count), do: "#{value}%20strings" + defp label(value, :reviewed_count), do: "#{value}%20reviewed" + defp label(value, :conflicts_count), do: "#{value}%20conflicts" + + defp merge_revisions_stats(revisions) do + counts = TranslationsCounter.from_revisions(revisions) + + revisions + |> Enum.map(&Revision.merge_stats(&1, counts)) + end + + defp merge_project_stats(revisions) do + revisions + |> Enum.reduce(%{translations_count: 0, conflicts_count: 0, reviewed_count: 0}, fn revision, acc -> + acc + |> Map.put(:translations_count, acc[:translations_count] + revision.translations_count) + |> Map.put(:conflicts_count, acc[:conflicts_count] + revision.conflicts_count) + |> Map.put(:reviewed_count, acc[:reviewed_count] + revision.reviewed_count) + end) + |> (fn + stats = %{translations_count: 0} -> + Map.put(stats, :percentage_reviewed_count, 0) + + stats -> + percentage_reviewed = + stats[:reviewed_count] + |> Kernel./(stats[:translations_count]) + |> Kernel.*(100) + |> Float.round(2) + |> PrettyFloat.convert() + + Map.put(stats, :percentage_reviewed_count, percentage_reviewed) + end).() + end +end diff --git a/lib/accent/collaborators/collaborator_creator.ex b/lib/accent/collaborators/collaborator_creator.ex new file mode 100644 index 00000000..33027510 --- /dev/null +++ b/lib/accent/collaborators/collaborator_creator.ex @@ -0,0 +1,21 @@ +defmodule Accent.CollaboratorCreator do + alias Accent.{Repo, Collaborator, User} + + def create(params) do + %Collaborator{} + |> Collaborator.create_changeset(params) + |> assign_user + |> Repo.insert() + end + + defp assign_user(collaborator) do + case fetch_user(collaborator.changes[:email]) do + %User{id: id} -> Ecto.Changeset.put_change(collaborator, :user_id, id) + nil -> collaborator + end + end + + defp fetch_user(email) do + Repo.get_by(User, email: email) + end +end diff --git a/lib/accent/collaborators/collaborator_updater.ex b/lib/accent/collaborators/collaborator_updater.ex new file mode 100644 index 00000000..b7195bc2 --- /dev/null +++ b/lib/accent/collaborators/collaborator_updater.ex @@ -0,0 +1,9 @@ +defmodule Accent.CollaboratorUpdater do + alias Accent.{Repo, Collaborator} + + def update(collaborator, params) do + collaborator + |> Collaborator.update_changeset(params) + |> Repo.update() + end +end diff --git a/lib/accent/endpoint.ex b/lib/accent/endpoint.ex new file mode 100644 index 00000000..ba241f17 --- /dev/null +++ b/lib/accent/endpoint.ex @@ -0,0 +1,45 @@ +defmodule Accent.Endpoint do + use Phoenix.Endpoint, otp_app: :accent + + socket("/socket", Accent.UserSocket) + + if Application.get_env(:accent, :force_ssl) do + plug(Plug.SSL, rewrite_on: [:x_forwarded_proto]) + end + + plug(Corsica, origins: "*", allow_headers: ~w(Accept Content-Type Authorization origin)) + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug(Plug.Static, at: "/static", from: "priv/static", gzip: false, only: ~w(images)) + plug(Plug.Static, at: "/", from: "priv/static/webapp", gzip: false, only: ~w(assets index.html)) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) + plug(Phoenix.LiveReloader) + plug(Phoenix.CodeReloader) + end + + plug(Plug.RequestId) + plug(Plug.Logger) + + plug( + Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Poison + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + + if Application.get_env(:accent, :sql_sandbox) do + plug(Phoenix.Ecto.SQL.Sandbox) + end + + plug(Accent.Router) +end diff --git a/lib/accent/integrations/integration_manager.ex b/lib/accent/integrations/integration_manager.ex new file mode 100644 index 00000000..eadd48fa --- /dev/null +++ b/lib/accent/integrations/integration_manager.ex @@ -0,0 +1,40 @@ +defmodule Accent.IntegrationManager do + alias Accent.{Repo, Integration} + + import Ecto.Changeset + + @spec create(map()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()} + def create(params) do + %Integration{} + |> changeset(params) + |> foreign_key_constraint(:user_id) + |> Repo.insert() + end + + @spec update(Integration.t(), map()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()} + def update(integration, params) do + integration + |> changeset(params) + |> Repo.update() + end + + @spec delete(Integration.t()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()} + def delete(integration) do + integration + |> Repo.delete() + end + + defp changeset(model, params) do + model + |> cast(params, [:project_id, :user_id, :service, :events]) + |> cast_embed(:data, with: &changeset_data/2) + |> foreign_key_constraint(:project_id) + |> validate_required([:service, :events, :data]) + end + + defp changeset_data(model, params) do + model + |> cast(params, [:url]) + |> validate_required([:url]) + end +end diff --git a/lib/accent/mailer.ex b/lib/accent/mailer.ex new file mode 100644 index 00000000..7fb7ceaf --- /dev/null +++ b/lib/accent/mailer.ex @@ -0,0 +1,3 @@ +defmodule Accent.Mailer do + use Bamboo.Mailer, otp_app: :accent +end diff --git a/lib/accent/operations/operation_batcher.ex b/lib/accent/operations/operation_batcher.ex new file mode 100644 index 00000000..18c810e0 --- /dev/null +++ b/lib/accent/operations/operation_batcher.ex @@ -0,0 +1,73 @@ +defmodule Accent.OperationBatcher do + import Ecto.Query, only: [from: 2] + + alias Accent.{Repo, Operation} + + @time_limit 60 + @time_unit "minute" + + def batch(%{batch_operation_id: id}) when not is_nil(id), do: {0, nil} + + def batch(operation) do + with existing_operation when not is_nil(existing_operation) <- find_existing_operation(operation), + batch_operation when not is_nil(batch_operation) <- maybe_batch(existing_operation) do + from( + o in Operation, + where: o.id in ^[existing_operation.id, operation.id] + ) + |> Repo.update_all(set: [batch_operation_id: batch_operation.id]) + else + _ -> {0, nil} + end + end + + defp find_existing_operation(%{action: action} = operation) when action in ["correct_conflict", "update"] do + case do_find_existing_operation(operation) do + nil -> nil + operation -> Repo.preload(operation, :batch_operation) + end + end + + defp do_find_existing_operation(%{action: action, id: id, inserted_at: inserted_at, user_id: user_id, revision_id: revision_id}) do + from( + operation in Operation, + where: operation.id != ^id, + where: [revision_id: ^revision_id], + where: [user_id: ^user_id], + where: [action: ^action], + where: [rollbacked: false], + where: operation.inserted_at >= datetime_add(^inserted_at, ^(-@time_limit), ^@time_unit), + order_by: [asc: :inserted_at], + limit: 1 + ) + |> Repo.one() + end + + defp maybe_batch(nil), do: nil + defp maybe_batch(operation = %{batch_operation_id: nil}), do: create_batch_operation(operation) + defp maybe_batch(%{batch_operation: batch_operation}), do: update_batch_operation(batch_operation) + + defp update_batch_operation(batch_operation) do + batch_operation + |> Operation.stats_changeset(%{stats: increment_stats_count(batch_operation)}) + |> Repo.update!() + end + + defp create_batch_operation(%{action: action, user_id: user_id, revision_id: revision_id}) do + %Operation{ + batch: true, + action: batch_operation_action(action), + revision_id: revision_id, + user_id: user_id, + stats: batch_operation_stats(action) + } + |> Repo.insert!() + end + + defp increment_stats_count(batch_operation) do + Enum.map(batch_operation.stats, fn stat -> update_in(stat, ["count"], fn count -> count + 1 end) end) + end + + defp batch_operation_action(action), do: "batch_" <> action + defp batch_operation_stats(action), do: [%{"count" => 2, "action" => action}] +end diff --git a/lib/accent/projects/project_creator.ex b/lib/accent/projects/project_creator.ex new file mode 100644 index 00000000..3aa93a3a --- /dev/null +++ b/lib/accent/projects/project_creator.ex @@ -0,0 +1,53 @@ +defmodule Accent.ProjectCreator do + import Ecto.Changeset + + alias Accent.{Project, Repo, User, UserRemote.TokenGiver} + alias Ecto.Changeset + + @required_fields ~w(name language_id)a + @bot %User{fullname: "API Client", bot: true} + + def create(params: params, user: user) do + changeset = + with changeset = %Changeset{valid?: true} <- cast_changeset(%Project{}, params), + changeset = %Changeset{valid?: true} <- build_master_revision(changeset), + changeset = %Changeset{valid?: true} <- build_collaborations(changeset, user), + do: changeset + + Repo.insert(changeset) + end + + def cast_changeset(model, params) do + model + |> cast(params, @required_fields ++ []) + |> validate_required(@required_fields) + end + + def build_master_revision(changeset) do + revision = + Ecto.build_assoc(changeset.data, :revisions, %{ + language_id: changeset.params["language_id"], + master: true + }) + + put_assoc(changeset, :revisions, [revision]) + end + + def build_collaborations(changeset, user) do + bot_user = generate_bot_user_with_access() + + bot = Ecto.build_assoc(changeset.data, :collaborators, %{role: "bot", email: bot_user.email, user_id: bot_user.id}) + owner = Ecto.build_assoc(changeset.data, :collaborators, %{role: "owner", email: user.email, user_id: user.id}) + + put_assoc(changeset, :collaborators, [owner, bot]) + end + + def generate_bot_user_with_access do + {:ok, bot_user, _token} = + @bot + |> Repo.insert!() + |> TokenGiver.grant_token() + + bot_user + end +end diff --git a/lib/accent/projects/project_deleter.ex b/lib/accent/projects/project_deleter.ex new file mode 100644 index 00000000..399a3834 --- /dev/null +++ b/lib/accent/projects/project_deleter.ex @@ -0,0 +1,11 @@ +defmodule Accent.ProjectDeleter do + alias Accent.Repo + + def delete(project: project) do + project + |> Ecto.assoc(:collaborators) + |> Repo.delete_all() + + {:ok, project} + end +end diff --git a/lib/accent/projects/project_updater.ex b/lib/accent/projects/project_updater.ex new file mode 100644 index 00000000..e7e4a8e4 --- /dev/null +++ b/lib/accent/projects/project_updater.ex @@ -0,0 +1,25 @@ +defmodule Accent.ProjectUpdater do + alias Accent.Repo + + import Canada, only: [can?: 2] + + @optional_fields ~w(name) + + def update(project: project, params: params, user: user) do + project + |> cast_changeset(params, user) + |> Repo.update() + end + + def cast_changeset(model, params, user) do + fields = + if user |> can?(locked_file_operations(model)) do + @optional_fields ++ ["locked_file_operations"] + else + @optional_fields + end + + model + |> Ecto.Changeset.cast(params, fields) + end +end diff --git a/lib/accent/repo.ex b/lib/accent/repo.ex new file mode 100644 index 00000000..8434e7dd --- /dev/null +++ b/lib/accent/repo.ex @@ -0,0 +1,4 @@ +defmodule Accent.Repo do + use Ecto.Repo, otp_app: :accent + use Scrivener, page_size: 30, max_page_size: 50 +end diff --git a/lib/accent/revisions/revision_deleter.ex b/lib/accent/revisions/revision_deleter.ex new file mode 100644 index 00000000..d3e68bda --- /dev/null +++ b/lib/accent/revisions/revision_deleter.ex @@ -0,0 +1,17 @@ +defmodule Accent.RevisionDeleter do + alias Ecto.Multi + alias Accent.Repo + + def delete(revision: %{master: true}), do: {:error, "can't delete master language"} + + def delete(revision: revision) do + translations = Ecto.assoc(revision, :translations) + operations = Ecto.assoc(revision, :operations) + + Multi.new() + |> Multi.delete_all(:operations, operations) + |> Multi.delete_all(:translations, translations) + |> Multi.delete(:revision, revision) + |> Repo.transaction() + end +end diff --git a/lib/accent/revisions/revision_master_promoter.ex b/lib/accent/revisions/revision_master_promoter.ex new file mode 100644 index 00000000..4eee5d0b --- /dev/null +++ b/lib/accent/revisions/revision_master_promoter.ex @@ -0,0 +1,30 @@ +defmodule Accent.RevisionMasterPromoter do + alias Accent.{Repo, Revision} + + require Ecto.Query + + import Ecto.Changeset + + def promote(revision: revision = %{master: true}) do + revision + |> change() + |> add_error(:master, "invalid") + |> Repo.update() + end + + def promote(revision: revision) do + revision + |> change() + |> put_change(:master, true) + |> put_change(:master_revision_id, nil) + |> prepare_changes(fn changeset -> + Revision + |> Ecto.Query.where([r], r.id != ^changeset.data.id) + |> Ecto.Query.where([r], r.project_id == ^changeset.data.project_id) + |> changeset.repo.update_all(set: [master_revision_id: changeset.data.id, master: false]) + + changeset + end) + |> Repo.update() + end +end diff --git a/lib/accent/schemas/access_token.ex b/lib/accent/schemas/access_token.ex new file mode 100644 index 00000000..46bedff5 --- /dev/null +++ b/lib/accent/schemas/access_token.ex @@ -0,0 +1,12 @@ +defmodule Accent.AccessToken do + use Accent.Schema + + schema "auth_access_tokens" do + field(:token, :string) + field(:revoked_at, :naive_datetime) + + belongs_to(:user, Accent.User) + + timestamps() + end +end diff --git a/lib/accent/schemas/auth_provider.ex b/lib/accent/schemas/auth_provider.ex new file mode 100644 index 00000000..f922c551 --- /dev/null +++ b/lib/accent/schemas/auth_provider.ex @@ -0,0 +1,12 @@ +defmodule Accent.AuthProvider do + use Accent.Schema + + schema "auth_providers" do + field(:name, :string) + field(:uid, :string) + + belongs_to(:user, Accent.User) + + timestamps() + end +end diff --git a/lib/accent/schemas/collaborator.ex b/lib/accent/schemas/collaborator.ex new file mode 100644 index 00000000..f76e3aad --- /dev/null +++ b/lib/accent/schemas/collaborator.ex @@ -0,0 +1,41 @@ +defmodule Accent.Collaborator do + use Accent.Schema + + require Accent.Role + + schema "collaborators" do + field(:email, :string) + field(:role, :string) + + belongs_to(:user, Accent.User) + belongs_to(:assigner, Accent.User) + belongs_to(:project, Accent.Project) + + timestamps() + end + + @required_fields ~w(email assigner_id role project_id)a + @optional_fields ~w(user_id)a + @possible_roles Accent.Role.slugs() + + def create_changeset(model, params) do + model + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_format(:email, ~r/.+@.+/) + |> downcase_email(params) + |> validate_inclusion(:role, @possible_roles) + end + + def update_changeset(model, params) do + model + |> cast(params, [:role]) + |> validate_inclusion(:role, @possible_roles) + end + + defp downcase_email(changeset, %{"email" => email}) do + put_change(changeset, :email, String.downcase(email)) + end + + defp downcase_email(changeset, _params), do: changeset +end diff --git a/lib/accent/schemas/comment.ex b/lib/accent/schemas/comment.ex new file mode 100644 index 00000000..988154a2 --- /dev/null +++ b/lib/accent/schemas/comment.ex @@ -0,0 +1,30 @@ +defmodule Accent.Comment do + use Accent.Schema + + import Ecto.Query, only: [from: 2] + + schema "comments" do + field(:text, :string) + + belongs_to(:translation, Accent.Translation) + belongs_to(:user, Accent.User) + + timestamps() + end + + @required_fields ~w(text user_id translation_id)a + + def changeset(model, params) do + model + |> cast(params, @required_fields ++ []) + |> validate_required(@required_fields) + |> assoc_constraint(:user) + |> assoc_constraint(:translation) + |> prepare_changes(fn changeset -> + from(t in Accent.Translation, where: t.id == ^changeset.changes[:translation_id]) + |> changeset.repo.update_all(inc: [comments_count: 1]) + + changeset + end) + end +end diff --git a/lib/accent/schemas/document.ex b/lib/accent/schemas/document.ex new file mode 100644 index 00000000..b84f7d1f --- /dev/null +++ b/lib/accent/schemas/document.ex @@ -0,0 +1,41 @@ +defmodule Accent.Document do + use Accent.Schema + + require Accent.DocumentFormat + + schema "documents" do + field(:path, :string) + + field(:format, :string) + field(:render, :string, virtual: true) + + field(:top_of_the_file_comment, :string, default: "") + field(:header, :string, default: "") + + belongs_to(:project, Accent.Project) + + field(:translations_count, :integer, virtual: true, default: :not_loaded) + field(:reviewed_count, :integer, virtual: true, default: :not_loaded) + field(:conflicts_count, :integer, virtual: true, default: :not_loaded) + + timestamps() + end + + @possible_formats Accent.DocumentFormat.slugs() + + def changeset(model, params) do + model + |> cast(params, [:format, :project_id, :path, :top_of_the_file_comment, :header]) + |> validate_required([:format, :path, :project_id]) + |> validate_inclusion(:format, @possible_formats) + |> unique_constraint(:path, name: :documents_path_format_project_id_index) + end + + def merge_stats(document, stats) do + translations_count = stats[document.id][:active] || 0 + conflicts_count = stats[document.id][:conflicted] || 0 + reviewed_count = translations_count - conflicts_count + + %{document | translations_count: translations_count, conflicts_count: conflicts_count, reviewed_count: reviewed_count} + end +end diff --git a/lib/accent/schemas/document_format.ex b/lib/accent/schemas/document_format.ex new file mode 100644 index 00000000..3ba10fea --- /dev/null +++ b/lib/accent/schemas/document_format.ex @@ -0,0 +1,45 @@ +defmodule Accent.DocumentFormat do + @enforce_keys ~w(name slug extension)a + defstruct name: nil, slug: nil, extension: nil + + @type t :: struct + + @all [ + %{name: "Simple JSON", slug: "simple_json", extension: "json"}, + %{name: "JSON", slug: "json", extension: "json"}, + %{name: "Apple .strings", slug: "strings", extension: "strings"}, + %{name: "Gettext", slug: "gettext", extension: "po"}, + %{name: "Rails YAML", slug: "rails_yml", extension: "yml"}, + %{name: "ES6 module", slug: "es6_module", extension: "js"}, + %{name: "Android XML", slug: "android_xml", extension: "xml"}, + %{name: "Java properties", slug: "java_properties", extension: "properties"}, + %{name: "Java properties XML", slug: "java_properties_xml", extension: "xml"} + ] + + @doc """ + Slugs used in document changeset validation + + ## Examples + iex> Accent.DocumentFormat.slugs() + ["simple_json", "json", "strings", "gettext", "rails_yml", "es6_module", "android_xml", "java_properties", "java_properties_xml"] + """ + defmacro slugs, do: Enum.map(@all, &Map.get(&1, :slug)) + + @doc """ + ## Examples + + iex> Accent.DocumentFormat.all() + [ + %Accent.DocumentFormat{extension: "json", name: "Simple JSON", slug: "simple_json"}, + %Accent.DocumentFormat{extension: "json", name: "JSON", slug: "json"}, + %Accent.DocumentFormat{extension: "strings", name: "Apple .strings", slug: "strings"}, + %Accent.DocumentFormat{extension: "po", name: "Gettext", slug: "gettext"}, + %Accent.DocumentFormat{extension: "yml", name: "Rails YAML", slug: "rails_yml"}, + %Accent.DocumentFormat{extension: "js", name: "ES6 module", slug: "es6_module"}, + %Accent.DocumentFormat{extension: "xml", name: "Android XML", slug: "android_xml"}, + %Accent.DocumentFormat{extension: "properties", name: "Java properties", slug: "java_properties"}, + %Accent.DocumentFormat{extension: "xml", name: "Java properties XML", slug: "java_properties_xml"} + ] + """ + def all, do: Enum.map(@all, &struct(__MODULE__, &1)) +end diff --git a/lib/accent/schemas/integration.ex b/lib/accent/schemas/integration.ex new file mode 100644 index 00000000..04a79afe --- /dev/null +++ b/lib/accent/schemas/integration.ex @@ -0,0 +1,14 @@ +defmodule Accent.Integration do + use Accent.Schema + + schema "integrations" do + field(:service, :string) + field(:events, {:array, :string}) + + embeds_one(:data, Accent.IntegrationData, on_replace: :update) + belongs_to(:project, Accent.Project) + belongs_to(:user, Accent.User) + + timestamps() + end +end diff --git a/lib/accent/schemas/integration_data.ex b/lib/accent/schemas/integration_data.ex new file mode 100644 index 00000000..a292ff8c --- /dev/null +++ b/lib/accent/schemas/integration_data.ex @@ -0,0 +1,7 @@ +defmodule Accent.IntegrationData do + use Accent.Schema + + schema "" do + field(:url) + end +end diff --git a/lib/accent/schemas/language.ex b/lib/accent/schemas/language.ex new file mode 100644 index 00000000..0a8d034a --- /dev/null +++ b/lib/accent/schemas/language.ex @@ -0,0 +1,17 @@ +defmodule Accent.Language do + use Accent.Schema + + schema "languages" do + field(:name, :string) + field(:slug, :string) + + field(:iso_639_1, :string) + field(:iso_639_3, :string) + field(:locale, :string) + field(:android_code, :string) + field(:osx_code, :string) + field(:osx_locale, :string) + + timestamps() + end +end diff --git a/lib/accent/schemas/operation.ex b/lib/accent/schemas/operation.ex new file mode 100644 index 00000000..a07fa2bf --- /dev/null +++ b/lib/accent/schemas/operation.ex @@ -0,0 +1,79 @@ +defmodule Accent.Operation do + use Accent.Schema + + @duplicated_fields [ + :action, + :key, + :text, + :conflicted, + :value_type, + :file_index, + :file_comment, + :removed, + :revision_id, + :translation_id, + :user_id, + :batch_operation_id, + :document_id, + :version_id, + :project_id, + :stats, + :previous_translation + ] + + schema "operations" do + field(:action, :string) + field(:key, :string) + field(:text, :string) + field(:batch, :boolean, default: false) + + field(:file_comment, :string) + field(:file_index, :integer) + + field(:value_type, :string) + + field(:previous_translation, :map) + field(:rollbacked, :boolean, default: false) + field(:stats, {:array, :map}, default: []) + + belongs_to(:document, Accent.Document) + belongs_to(:revision, Accent.Revision) + belongs_to(:version, Accent.Version) + belongs_to(:translation, Accent.Translation) + belongs_to(:project, Accent.Project) + belongs_to(:comment, Accent.Comment) + belongs_to(:user, Accent.User) + belongs_to(:batch_operation, Accent.Operation) + belongs_to(:rollbacked_operation, Accent.Operation) + + has_one(:rollback_operation, Accent.Operation, foreign_key: :rollbacked_operation_id) + has_many(:operations, Accent.Operation, foreign_key: :batch_operation_id) + + field(:language_id, :string, virtual: true) + + timestamps() + end + + @optional_fields [ + :rollbacked, + :translation_id, + :comment_id + ] + def changeset(model, params) do + model + |> cast(params, [] ++ @optional_fields) + end + + def stats_changeset(model, params) do + model + |> cast(params, [:stats]) + end + + def copy(operation, new_fields) do + duplicated_operation = Map.take(operation, @duplicated_fields) + + %__MODULE__{} + |> Map.merge(duplicated_operation) + |> Map.merge(new_fields) + end +end diff --git a/lib/accent/schemas/previous_translation.ex b/lib/accent/schemas/previous_translation.ex new file mode 100644 index 00000000..109760f6 --- /dev/null +++ b/lib/accent/schemas/previous_translation.ex @@ -0,0 +1,38 @@ +defmodule Accent.PreviousTranslation do + @doc """ + ## Examples + + iex> Accent.PreviousTranslation.from_translation(nil) + %{} + iex> Accent.PreviousTranslation.from_translation(%{}) + %{} + iex> Accent.PreviousTranslation.from_translation(%Accent.Translation{proposed_text: "a", corrected_text: "b", conflicted_text: "c", conflicted: true, removed: false, value_type: "text"}) + %{"proposed_text" => "a", "corrected_text" => "b", "conflicted_text" => "c", "conflicted" => true, "removed" => false, "value_type" => "text"} + iex> Accent.PreviousTranslation.to_translation(%{"proposed_text" => "a", "corrected_text" => "b", "conflicted_text" => "c", "conflicted" => true, "removed" => false, "value_type" => "text"}) + %{proposed_text: "a", corrected_text: "b", conflicted_text: "c", conflicted: true, removed: false, value_type: "text"} + """ + def from_translation(nil), do: %{} + def from_translation(translation) when map_size(translation) == 0, do: %{} + + def from_translation(translation) do + %{ + "proposed_text" => translation.proposed_text, + "corrected_text" => translation.corrected_text, + "conflicted_text" => translation.conflicted_text, + "conflicted" => translation.conflicted, + "removed" => translation.removed, + "value_type" => translation.value_type + } + end + + def to_translation(translation) do + %{ + proposed_text: translation["proposed_text"], + corrected_text: translation["corrected_text"], + conflicted_text: translation["conflicted_text"], + conflicted: translation["conflicted"], + removed: translation["removed"], + value_type: translation["value_type"] + } + end +end diff --git a/lib/accent/schemas/project.ex b/lib/accent/schemas/project.ex new file mode 100644 index 00000000..d0288d62 --- /dev/null +++ b/lib/accent/schemas/project.ex @@ -0,0 +1,29 @@ +defmodule Accent.Project do + use Accent.Schema + + schema "projects" do + field(:name, :string) + field(:last_synced_at, :naive_datetime) + field(:locked_file_operations, :boolean, default: false) + + has_many(:integrations, Accent.Integration) + has_many(:revisions, Accent.Revision) + has_many(:versions, Accent.Version) + has_many(:operations, Accent.Operation) + has_many(:collaborators, Accent.Collaborator) + belongs_to(:language, Accent.Language) + + timestamps() + end + + @optional_fields [ + :name, + :last_synced_at, + :locked_file_operations + ] + def changeset(model, params) do + model + |> cast(params, @optional_fields) + |> validate_required([:name]) + end +end diff --git a/lib/accent/schemas/revision.ex b/lib/accent/schemas/revision.ex new file mode 100644 index 00000000..60df4988 --- /dev/null +++ b/lib/accent/schemas/revision.ex @@ -0,0 +1,39 @@ +defmodule Accent.Revision do + use Accent.Schema + + schema "revisions" do + field(:master, :boolean, default: true) + + belongs_to(:master_revision, Accent.Revision) + belongs_to(:project, Accent.Project) + belongs_to(:language, Accent.Language) + + has_many(:translations, Accent.Translation) + has_many(:operations, Accent.Operation) + + field(:translations_count, :integer, virtual: true, default: :not_loaded) + field(:reviewed_count, :integer, virtual: true, default: :not_loaded) + field(:conflicts_count, :integer, virtual: true, default: :not_loaded) + + field(:translation_ids, {:array, :string}, virtual: true) + + timestamps() + end + + @required_fields [:language_id, :project_id, :master_revision_id, :master] + + def changeset(model, params) do + model + |> cast(params, @required_fields ++ []) + |> validate_required(@required_fields) + |> unique_constraint(:language, name: :revisions_project_id_language_id_index) + end + + def merge_stats(revision, stats) do + translations_count = stats[revision.id][:active] || 0 + conflicts_count = stats[revision.id][:conflicted] || 0 + reviewed_count = translations_count - conflicts_count + + %{revision | translations_count: translations_count, conflicts_count: conflicts_count, reviewed_count: reviewed_count} + end +end diff --git a/lib/accent/schemas/role.ex b/lib/accent/schemas/role.ex new file mode 100644 index 00000000..5315e3de --- /dev/null +++ b/lib/accent/schemas/role.ex @@ -0,0 +1,33 @@ +defmodule Accent.Role do + defstruct slug: nil + + @type t :: struct + + @all [ + %{slug: "owner"}, + %{slug: "admin"}, + %{slug: "developer"}, + %{slug: "reviewer"} + ] + + @doc """ + ## Examples + + iex> Accent.Role.slugs() + ["owner", "admin", "developer", "reviewer"] + """ + defmacro slugs, do: Enum.map(@all, &Map.get(&1, :slug)) + + @doc """ + ## Examples + + iex> Accent.Role.all() + [ + %Accent.Role{slug: "owner"}, + %Accent.Role{slug: "admin"}, + %Accent.Role{slug: "developer"}, + %Accent.Role{slug: "reviewer"} + ] + """ + def all, do: Enum.map(@all, &struct(__MODULE__, &1)) +end diff --git a/lib/accent/schemas/schema.ex b/lib/accent/schemas/schema.ex new file mode 100644 index 00000000..4ad6e104 --- /dev/null +++ b/lib/accent/schemas/schema.ex @@ -0,0 +1,14 @@ +defmodule Accent.Schema do + defmacro __using__(_) do + quote do + use Ecto.Schema + + @type t :: struct + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + import Ecto + import Ecto.Changeset + end + end +end diff --git a/lib/accent/schemas/translation.ex b/lib/accent/schemas/translation.ex new file mode 100644 index 00000000..4552c07f --- /dev/null +++ b/lib/accent/schemas/translation.ex @@ -0,0 +1,49 @@ +defmodule Accent.Translation do + use Accent.Schema + + schema "translations" do + field(:key, :string) + field(:proposed_text, :string, default: "") + field(:corrected_text, :string, default: "") + field(:conflicted_text, :string, default: "") + field(:conflicted, :boolean, default: false) + field(:removed, :boolean, default: false) + field(:comments_count, :integer, default: 0) + + field(:file_comment, :string) + field(:file_index, :integer) + + field(:value_type, :string) + + belongs_to(:document, Accent.Document) + belongs_to(:revision, Accent.Revision) + has_one(:project, through: [:revision, :project]) + belongs_to(:version, Accent.Version) + belongs_to(:source_translation, __MODULE__) + has_many(:operations, Accent.Operation) + has_many(:comments, Accent.Comment) + has_many(:comments_subscriptions, Accent.TranslationCommentsSubscription) + + field(:marked_as_removed, :string, virtual: true) + field(:text, :string, virtual: true) + + timestamps() + end + + @optional_fields [ + :proposed_text, + :corrected_text, + :conflicted_text, + :conflicted, + :removed, + :comments_count, + :file_index, + :file_comment, + :value_type, + :document_id + ] + def changeset(model, params) do + model + |> cast(params, @optional_fields) + end +end diff --git a/lib/accent/schemas/translation_comments_subscription.ex b/lib/accent/schemas/translation_comments_subscription.ex new file mode 100644 index 00000000..7a20171e --- /dev/null +++ b/lib/accent/schemas/translation_comments_subscription.ex @@ -0,0 +1,21 @@ +defmodule Accent.TranslationCommentsSubscription do + use Accent.Schema + + schema "translation_comments_subscriptions" do + belongs_to(:user, Accent.User) + belongs_to(:translation, Accent.Translation) + + timestamps() + end + + @required_fields [ + :user_id, + :translation_id + ] + def changeset(model, params) do + model + |> cast(params, @required_fields) + |> validate_required(@required_fields) + |> unique_constraint(:user, name: :translation_comments_subscriptions_user_id_translation_id_index) + end +end diff --git a/lib/accent/schemas/user.ex b/lib/accent/schemas/user.ex new file mode 100644 index 00000000..8fa79d3b --- /dev/null +++ b/lib/accent/schemas/user.ex @@ -0,0 +1,30 @@ +defmodule Accent.User do + use Accent.Schema + + schema "users" do + field(:email, :string) + field(:fullname, :string) + field(:picture_url, :string) + field(:bot, :boolean, default: false) + + has_many(:access_tokens, Accent.AccessToken) + has_many(:auth_providers, Accent.AuthProvider) + has_many(:collaborations, Accent.Collaborator) + has_many(:collaboration_assigns, Accent.Collaborator, foreign_key: :assigner_id) + + field(:permissions, :map, virtual: true) + + timestamps() + end + + @doc """ + ## Examples + + iex> Accent.User.name_with_fallback(%{fullname: "test", email: "foo@bar.com"}) + "test" + iex> Accent.User.name_with_fallback(%{fullname: nil, email: "foo@bar.com"}) + "foo@bar.com" + """ + def name_with_fallback(%{fullname: fullname, email: email}) when is_nil(fullname), do: email + def name_with_fallback(%{fullname: fullname}), do: fullname +end diff --git a/lib/accent/schemas/version.ex b/lib/accent/schemas/version.ex new file mode 100644 index 00000000..ff81bff1 --- /dev/null +++ b/lib/accent/schemas/version.ex @@ -0,0 +1,25 @@ +defmodule Accent.Version do + use Accent.Schema + + schema "versions" do + field(:name, :string) + field(:tag, :string) + + belongs_to(:user, Accent.User) + belongs_to(:project, Accent.Project) + + has_many(:translations, Accent.Translation) + has_many(:operations, Accent.Operation) + + timestamps() + end + + @required_fields [:project_id, :user_id, :name, :tag] + + def changeset(model, params) do + model + |> cast(params, @required_fields) + |> validate_required(@required_fields) + |> unique_constraint(:tag, name: :versions_tag_project_id_index) + end +end diff --git a/lib/accent/scopes/comment.ex b/lib/accent/scopes/comment.ex new file mode 100644 index 00000000..e4c49d2e --- /dev/null +++ b/lib/accent/scopes/comment.ex @@ -0,0 +1,33 @@ +defmodule Accent.Scopes.Comment do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Comment.default_order(Accent.Comment) + #Ecto.Query + """ + @spec default_order(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def default_order(query) do + from(c in query, order_by: [desc: :inserted_at]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Comment.from_project(Accent.Comment, "test") + #Ecto.Query + """ + @spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_project(query, project_id) do + from( + comment in query, + inner_join: translation in assoc(comment, :translation), + inner_join: revision in assoc(translation, :revision), + inner_join: project in assoc(revision, :project), + where: project.id == ^project_id, + order_by: [desc: comment.inserted_at], + select: comment + ) + end +end diff --git a/lib/accent/scopes/document.ex b/lib/accent/scopes/document.ex new file mode 100644 index 00000000..78e10783 --- /dev/null +++ b/lib/accent/scopes/document.ex @@ -0,0 +1,25 @@ +defmodule Accent.Scopes.Document do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Document.from_project(Accent.Document, "test") + #Ecto.Query + """ + @spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_project(query, project_id) do + from(d in query, where: [project_id: ^project_id]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Document.from_path(Accent.Document, "test") + #Ecto.Query + """ + @spec from_path(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_path(query, path) do + from(d in query, where: [path: ^path]) + end +end diff --git a/lib/accent/scopes/language.ex b/lib/accent/scopes/language.ex new file mode 100644 index 00000000..014ee89f --- /dev/null +++ b/lib/accent/scopes/language.ex @@ -0,0 +1,26 @@ +defmodule Accent.Scopes.Language do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Language.from_search(Accent.Language, "") + Accent.Language + iex> Accent.Scopes.Language.from_search(Accent.Language, nil) + Accent.Language + iex> Accent.Scopes.Language.from_search(Accent.Language, 1234) + Accent.Language + iex> Accent.Scopes.Language.from_search(Accent.Language, "test") + #Ecto.Query + """ + @spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_search(query, nil), do: query + def from_search(query, term) when term === "", do: query + def from_search(query, term) when not is_binary(term), do: query + + def from_search(query, term) do + term = "%" <> term <> "%" + + from(l in query, where: ilike(l.name, ^term)) + end +end diff --git a/lib/accent/scopes/operation.ex b/lib/accent/scopes/operation.ex new file mode 100644 index 00000000..4c4105a4 --- /dev/null +++ b/lib/accent/scopes/operation.ex @@ -0,0 +1,79 @@ +defmodule Accent.Scopes.Operation do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Operation.filter_from_user(Accent.Operation, nil) + Accent.Operation + iex> Accent.Scopes.Operation.filter_from_user(Accent.Operation, "test") + #Ecto.Query + """ + @spec filter_from_user(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def filter_from_user(query, nil), do: query + + def filter_from_user(query, user_id) do + from(o in query, where: [user_id: ^user_id]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Operation.filter_from_action(Accent.Operation, nil) + Accent.Operation + iex> Accent.Scopes.Operation.filter_from_action(Accent.Operation, "test") + #Ecto.Query + """ + @spec filter_from_action(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def filter_from_action(query, nil), do: query + + def filter_from_action(query, action) do + from(o in query, where: [action: ^action]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, nil) + Accent.Operation + iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, "test") + Accent.Operation + iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, true) + #Ecto.Query + """ + @spec filter_from_batch(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def filter_from_batch(query, nil), do: query + def filter_from_batch(query, batch) when not is_boolean(batch), do: query + + def filter_from_batch(query, batch) do + from(o in query, where: [batch: ^batch]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Operation.order_last_to_first(Accent.Operation) + #Ecto.Query + """ + @spec order_last_to_first(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def order_last_to_first(query) do + from(o in query, order_by: [desc: :inserted_at, asc: :batch]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, "action", nil) + Accent.Operation + iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, nil, true) + Accent.Operation + iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, nil, nil) + #Ecto.Query + """ + @spec ignore_actions(Ecto.Queryable.t(), any(), any()) :: Ecto.Queryable.t() + def ignore_actions(query, nil, nil) do + from(o in query, where: is_nil(o.batch_operation_id)) + end + + def ignore_actions(query, _action_argument, _batch_argument), do: query +end diff --git a/lib/accent/scopes/project.ex b/lib/accent/scopes/project.ex new file mode 100644 index 00000000..a97705b1 --- /dev/null +++ b/lib/accent/scopes/project.ex @@ -0,0 +1,26 @@ +defmodule Accent.Scopes.Project do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Project.from_search(Accent.Project, "") + Accent.Project + iex> Accent.Scopes.Project.from_search(Accent.Project, nil) + Accent.Project + iex> Accent.Scopes.Project.from_search(Accent.Project, 1234) + Accent.Project + iex> Accent.Scopes.Project.from_search(Accent.Project, "test") + #Ecto.Query + """ + @spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_search(query, nil), do: query + def from_search(query, term) when term === "", do: query + def from_search(query, term) when not is_binary(term), do: query + + def from_search(query, term) do + term = "%" <> term <> "%" + + from(p in query, where: ilike(p.name, ^term)) + end +end diff --git a/lib/accent/scopes/revision.ex b/lib/accent/scopes/revision.ex new file mode 100644 index 00000000..a8ed5b0c --- /dev/null +++ b/lib/accent/scopes/revision.ex @@ -0,0 +1,52 @@ +defmodule Accent.Scopes.Revision do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Revision.from_project(Accent.Revision, "test") + #Ecto.Query + """ + @spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_project(query, project_id) do + from( + revision in query, + where: [project_id: ^project_id], + inner_join: language in assoc(revision, :language), + order_by: [desc: revision.master, asc: language.name] + ) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Revision.from_language(Accent.Revision, "test") + #Ecto.Query + """ + @spec from_language(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_language(query, language_id) do + from(r in query, where: [language_id: ^language_id]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Revision.master(Accent.Revision) + #Ecto.Query + """ + @spec master(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def master(query) do + from(r in query, where: [master: true]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Revision.slaves(Accent.Revision) + #Ecto.Query + """ + @spec slaves(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def slaves(query) do + from(r in query, where: [master: false]) + end +end diff --git a/lib/accent/scopes/translation.ex b/lib/accent/scopes/translation.ex new file mode 100644 index 00000000..b169da66 --- /dev/null +++ b/lib/accent/scopes/translation.ex @@ -0,0 +1,236 @@ +defmodule Accent.Scopes.Translation do + import Ecto.Query + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.not_id(Accent.Translation, "test") + #Ecto.Query + """ + @spec not_id(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def not_id(query, id) do + from(t in query, where: t.id != ^id) + end + + @doc """ + Default ordering is by ascending key + + ## Examples + + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, nil) + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "key") + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-key") + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "updated") + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-updated") + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "index") + #Ecto.Query + iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-index") + #Ecto.Query + """ + @spec parse_order(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def parse_order(query, "index"), do: from(t in query, order_by: [asc: :file_index]) + def parse_order(query, "-index"), do: from(t in query, order_by: [desc: :file_index]) + def parse_order(query, "key"), do: from(t in query, order_by: [asc: :key]) + def parse_order(query, "-key"), do: from(t in query, order_by: [desc: :key]) + def parse_order(query, "updated"), do: from(t in query, order_by: [asc: :updated_at]) + def parse_order(query, "-updated"), do: from(t in query, order_by: [desc: :updated_at]) + def parse_order(query, _), do: from(t in query, order_by: [asc: :key]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.active(Accent.Translation) + #Ecto.Query + """ + @spec active(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def active(query), do: from(t in query, where: [removed: false]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, nil) + Accent.Translation + iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, false) + #Ecto.Query + iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, true) + #Ecto.Query + """ + @spec parse_conflicted(Ecto.Queryable.t(), nil | boolean()) :: Ecto.Queryable.t() + def parse_conflicted(query, nil), do: query + def parse_conflicted(query, false), do: not_conflicted(query) + def parse_conflicted(query, true), do: conflicted(query) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.conflicted(Accent.Translation) + #Ecto.Query + """ + @spec conflicted(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def conflicted(query), do: from(t in query, where: [conflicted: true]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.not_conflicted(Accent.Translation) + #Ecto.Query + """ + @spec not_conflicted(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def not_conflicted(query), do: from(t in query, where: [conflicted: false]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.no_version(Accent.Translation) + #Ecto.Query + """ + @spec no_version(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def no_version(query), do: from_version(query, nil) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_version(Accent.Translation, nil) + #Ecto.Query + iex> Accent.Scopes.Translation.from_version(Accent.Translation, "test") + #Ecto.Query + """ + @spec from_version(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_version(query, nil), do: from(t in query, where: is_nil(t.version_id)) + def from_version(query, version_id), do: from(t in query, where: [version_id: ^version_id]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_revision(Accent.Translation, "test") + #Ecto.Query + """ + @spec from_revision(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_revision(query, revision_id), do: from(t in query, where: [revision_id: ^revision_id]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_revisions(Accent.Translation, ["test"]) + #Ecto.Query + """ + @spec from_revision(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t() + def from_revisions(query, revision_ids), do: from(t in query, where: t.revision_id in ^revision_ids) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_project(Accent.Translation, "test") + #Ecto.Query + """ + @spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_project(query, project_id) do + from( + translation in query, + left_join: project in assoc(translation, :project), + where: project.id == ^project_id + ) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_document(Accent.Translation, nil) + #Ecto.Query + iex> Accent.Scopes.Translation.from_document(Accent.Translation, :all) + Accent.Translation + iex> Accent.Scopes.Translation.from_document(Accent.Translation, "test") + #Ecto.Query + """ + @spec from_document(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_document(query, nil), do: from(t in query, where: is_nil(t.document_id)) + def from_document(query, :all), do: query + def from_document(query, document_id), do: from(t in query, where: [document_id: ^document_id]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_documents(Accent.Translation, ["test"]) + #Ecto.Query + """ + @spec from_documents(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t() + def from_documents(query, document_ids), do: from(t in query, where: t.document_id in ^document_ids) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_key(Accent.Translation, "test") + #Ecto.Query + """ + @spec from_key(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() + def from_key(query, key), do: from(t in query, where: [key: ^key]) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_keys(Accent.Translation, ["test"]) + #Ecto.Query + """ + @spec from_keys(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t() + def from_keys(query, key_ids), do: from(t in query, where: t.key in ^key_ids) + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.from_search(Accent.Translation, "") + Accent.Translation + iex> Accent.Scopes.Translation.from_search(Accent.Translation, nil) + Accent.Translation + iex> Accent.Scopes.Translation.from_search(Accent.Translation, 1234) + Accent.Translation + iex> Accent.Scopes.Translation.from_search(Accent.Translation, "test") + #Ecto.Query + iex> Accent.Scopes.Translation.from_search(Accent.Translation, "030519c4-1d47-42bb-95ee-205880be01d9") + #Ecto.Query + """ + @spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_search(query, nil), do: query + def from_search(query, term) when term === "", do: query + def from_search(query, term) when not is_binary(term), do: query + + def from_search(query, search_term) do + term = "%" <> search_term <> "%" + + from( + translation in query, + where: ilike(translation.key, ^term) or ilike(translation.corrected_text, ^term) + ) + |> from_search_id(search_term) + end + + defp from_search_id(query, key) do + case Ecto.UUID.cast(key) do + {:ok, uuid} -> from(t in query, or_where: [id: ^uuid]) + _ -> query + end + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Translation.select_key_text(Accent.Translation) + #Ecto.Query + """ + @spec select_key_text(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def select_key_text(query) do + from( + translation in query, + select: %{ + id: translation.id, + key: translation.key, + updated_at: translation.updated_at, + corrected_text: translation.corrected_text + } + ) + end +end diff --git a/lib/accent/scopes/version.ex b/lib/accent/scopes/version.ex new file mode 100644 index 00000000..339a5063 --- /dev/null +++ b/lib/accent/scopes/version.ex @@ -0,0 +1,33 @@ +defmodule Accent.Scopes.Version do + import Ecto.Query, only: [from: 2] + + @doc """ + ## Examples + + iex> Accent.Scopes.Version.from_project(Accent.Version, "test") + #Ecto.Query + iex> Accent.Scopes.Version.from_project(Accent.Version, nil) + Accent.Version + """ + @spec from_project(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_project(query, nil), do: query + + def from_project(query, project_id) do + from(v in query, where: [project_id: ^project_id]) + end + + @doc """ + ## Examples + + iex> Accent.Scopes.Version.from_tag(Accent.Version, "test") + #Ecto.Query + iex> Accent.Scopes.Version.from_tag(Accent.Version, nil) + Accent.Version + """ + @spec from_tag(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t() + def from_tag(query, nil), do: query + + def from_tag(query, tag) do + from(v in query, where: [tag: ^tag]) + end +end diff --git a/lib/accent/translations/translations_counter.ex b/lib/accent/translations/translations_counter.ex new file mode 100644 index 00000000..588154c4 --- /dev/null +++ b/lib/accent/translations/translations_counter.ex @@ -0,0 +1,65 @@ +defmodule Accent.TranslationsCounter do + @moduledoc """ + From documents or revisions, computed the active and conflicted translations count. + It counts them in an efficient way (no n+1 queries). + """ + + import Ecto.Query + + alias Accent.Repo + alias Accent.Scopes.Translation, as: Scope + alias Accent.Translation + + @spec from_documents(list(Accent.Document.t())) :: struct + def from_documents(documents) do + from_assoc(documents, :document_id, &Scope.from_documents/2) + end + + @spec from_revisions(list(Accent.Revision.t())) :: struct + def from_revisions(revisions) do + from_assoc(revisions, :revision_id, &Scope.from_revisions/2) + end + + defp from_assoc(associations, assoc_name, scope_filter_ids) do + scope = + Translation + |> Scope.active() + |> Scope.no_version() + + active = + scope + |> select([t], %{id: field(t, ^assoc_name), active: count(t.id)}) + |> group_items(associations, assoc_name, scope_filter_ids) + |> Repo.all() + + conflicted = + scope + |> Scope.conflicted() + |> select([t], %{id: field(t, ^assoc_name), conflicted: count(t.id)}) + |> group_items(associations, assoc_name, scope_filter_ids) + |> Repo.all() + + active + |> Kernel.++(conflicted) + |> Enum.group_by(&Map.get(&1, :id)) + |> Enum.reduce(%{}, &count_from_items/2) + end + + defp count_from_items({key, items}, acc) do + case items do + [%{active: active}, %{conflicted: conflicted}] -> + Map.put_new(acc, key, %{conflicted: conflicted, active: active}) + + [%{active: active}] -> + Map.put_new(acc, key, %{conflicted: 0, active: active}) + end + end + + defp group_items(query, associations, assoc_name, scope_filter_ids) do + association_ids = Enum.map(associations, &Map.get(&1, :id)) + + from(t in query) + |> scope_filter_ids.(association_ids) + |> group_by([t], field(t, ^assoc_name)) + end +end diff --git a/lib/accent/translations/translations_renderer.ex b/lib/accent/translations/translations_renderer.ex new file mode 100644 index 00000000..c10cb8db --- /dev/null +++ b/lib/accent/translations/translations_renderer.ex @@ -0,0 +1,58 @@ +defmodule Accent.TranslationsRenderer do + alias Langue.Formatter.Strings.Serializer, as: StringsSerializer + alias Langue.Formatter.Rails.Serializer, as: RailsSerializer + alias Langue.Formatter.Json.Serializer, as: JsonSerializer + alias Langue.Formatter.SimpleJson.Serializer, as: SimpleJsonSerializer + alias Langue.Formatter.Es6Module.Serializer, as: Es6ModuleSerializer + alias Langue.Formatter.Android.Serializer, as: AndroidSerializer + alias Langue.Formatter.JavaProperties.Serializer, as: JavaPropertiesSerializer + alias Langue.Formatter.JavaPropertiesXml.Serializer, as: JavaPropertiesXmlSerializer + alias Langue.Formatter.Gettext.Serializer, as: GettextSerializer + + def render(args) do + serializer = fetch_serializer(args[:document_format]) + entries = fetch_entries(args[:translations]) + + parser_result = %Langue.Formatter.ParserResult{ + entries: entries, + locale: args[:document_locale], + top_of_the_file_comment: args[:document_top_of_the_file_comment], + header: args[:document_header] + } + + try do + serializer.(parser_result) + catch + _ -> Langue.Formatter.SerializerResult.empty() + end + end + + defp fetch_serializer(format) do + case serializer_from_format(format) do + {:ok, serializer} -> serializer + end + end + + defp fetch_entries(translations) do + Enum.map(translations, fn translation -> + %Langue.Entry{ + key: translation.key, + value: translation.corrected_text, + comment: translation.file_comment, + index: translation.file_index, + value_type: translation.value_type + } + end) + end + + defp serializer_from_format("strings"), do: {:ok, &StringsSerializer.serialize/1} + defp serializer_from_format("rails_yml"), do: {:ok, &RailsSerializer.serialize/1} + defp serializer_from_format("json"), do: {:ok, &JsonSerializer.serialize/1} + defp serializer_from_format("simple_json"), do: {:ok, &SimpleJsonSerializer.serialize/1} + defp serializer_from_format("android_xml"), do: {:ok, &AndroidSerializer.serialize/1} + defp serializer_from_format("es6_module"), do: {:ok, &Es6ModuleSerializer.serialize/1} + defp serializer_from_format("java_properties"), do: {:ok, &JavaPropertiesSerializer.serialize/1} + defp serializer_from_format("java_properties_xml"), do: {:ok, &JavaPropertiesXmlSerializer.serialize/1} + defp serializer_from_format("gettext"), do: {:ok, &GettextSerializer.serialize/1} + defp serializer_from_format(_), do: {:error, :unknown_serializer} +end diff --git a/lib/accessible.ex b/lib/accessible.ex new file mode 100644 index 00000000..1bf1166b --- /dev/null +++ b/lib/accessible.ex @@ -0,0 +1,53 @@ +defmodule Accessible do + @moduledoc false + + defmacro __using__(_) do + quote location: :keep do + @behaviour Access + + def fetch(struct, key), do: Map.fetch(struct, key) + + def get(struct, key, default \\ nil) do + case struct do + %{^key => value} -> value + _else -> default + end + end + + def put(struct, key, val) do + if Map.has_key?(struct, key) do + Map.put(struct, key, val) + else + struct + end + end + + def delete(struct, key) do + put(struct, key, struct(__MODULE__)[key]) + end + + def get_and_update(struct, key, fun) when is_function(fun, 1) do + current = get(struct, key) + + case fun.(current) do + {value, update} -> + {value, put(struct, key, update)} + + :pop -> + {current, delete(struct, key)} + + other -> + raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}" + end + end + + def pop(struct, key, default \\ nil) do + val = get(struct, key, default) + updated = delete(struct, key) + {val, updated} + end + + defoverridable fetch: 2, get: 3, put: 3, delete: 2, get_and_update: 3, pop: 3 + end + end +end diff --git a/lib/graphql/datetime_scalar.ex b/lib/graphql/datetime_scalar.ex new file mode 100644 index 00000000..dee77ca5 --- /dev/null +++ b/lib/graphql/datetime_scalar.ex @@ -0,0 +1,56 @@ +defmodule Accent.GraphQL.DatetimeScalar do + use Absinthe.Schema.Notation + + @moduledoc """ + This module contains additional data types. + To use: `import_types Absinthe.Type.Extensions`. + """ + + @utc_timezone "Etc/UTC" + + scalar :datetime, name: "DateTime" do + description(""" + The `DateTime` scalar type represents a date and time in the UTC + timezone. The DateTime appears in a JSON response as an ISO8601 formatted + string, including UTC timezone ("Z"). + """) + + serialize(&serialize_datetime/1) + parse(parse_with([Absinthe.Blueprint.Input.DateTime], &parse_datetime/1)) + end + + @spec parse_datetime(any) :: {:ok, DateTime.t()} | :error + defp parse_datetime(value) when is_binary(value) do + NaiveDateTime.from_iso8601(value) + end + + defp parse_datetime(_) do + :error + end + + @spec serialize_datetime(any) :: {:ok, String.t()} | :error + defp serialize_datetime(datetime = %NaiveDateTime{}) do + datetime + |> DateTime.from_naive!(@utc_timezone) + |> DateTime.to_iso8601() + end + + defp serialize_datetime(_) do + :error + end + + # Parse, supporting pulling values out of blueprint Input nodes + defp parse_with(node_types, coercion) do + fn + %{__struct__: str, value: value} -> + if Enum.member?(node_types, str) do + coercion.(value) + else + :error + end + + other -> + coercion.(other) + end + end +end diff --git a/lib/graphql/helpers/authorization.ex b/lib/graphql/helpers/authorization.ex new file mode 100644 index 00000000..8c307fc1 --- /dev/null +++ b/lib/graphql/helpers/authorization.ex @@ -0,0 +1,151 @@ +defmodule Accent.GraphQL.Helpers.Authorization do + import Accent.GraphQL.Plugins.Authorization + + alias Accent.{ + Project, + Version, + Translation, + Revision, + Collaborator, + TranslationCommentsSubscription, + Document, + Operation, + Integration, + Repo + } + + def viewer_authorize(_action, func) do + fn + %{user: nil}, _args, _info -> + {:ok, nil} + + user, args, info -> + func.(user, args, info) + end + end + + def project_authorize(action, func, id \\ :id) do + fn + project = %Project{}, args, info -> + authorize(action, project.id, info, do: func.(project, args, info)) + + _, args, info -> + project = Repo.get(Project, args[id]) || %{id: nil} + + authorize(action, project.id, info, do: func.(project, args, info)) + end + end + + def revision_authorize(action, func) do + fn + revision = %Revision{}, args, info -> + authorize(action, revision.project_id, info, do: func.(revision, args, info)) + + _, args, info -> + revision = + Revision + |> Repo.get(args.id) + |> Repo.preload(:language) + + authorize(action, revision.project_id, info, do: func.(revision, args, info)) + end + end + + def version_authorize(action, func) do + fn + version = %Version{}, args, info -> + authorize(action, version.project_id, info, do: func.(version, args, info)) + + _, args, info -> + version = + Version + |> Repo.get(args.id) + + authorize(action, version.project_id, info, do: func.(version, args, info)) + end + end + + def translation_authorize(action, func) do + fn + translation = %Translation{}, args, info -> + revision = + case translation.revision do + %Revision{} = revision -> + revision + + _ -> + translation |> Ecto.assoc(:revision) |> Repo.one() + end + + authorize(action, revision.project_id, info, do: func.(translation, args, info)) + + _, args, info -> + id = args[:id] || args[:translation_id] + + translation = + Translation + |> Repo.get(id) + |> Repo.preload(revision: [:project]) + + authorize(action, translation.revision.project_id, info, do: func.(translation, args, info)) + end + end + + def document_authorize(action, func) do + fn _, args, info -> + document = Repo.get(Document, args.id) + + authorize(action, document.project_id, info, do: func.(document, args, info)) + end + end + + def operation_authorize(action, func) do + fn _, args, info -> + operation = + Operation + |> Repo.get(args[:id]) + |> Repo.preload([:revision, [translation: [:revision]]]) + + project_id = + case operation do + %{translation: %{revision: %{project_id: id}}} -> id + %{revision: %{project_id: id}} -> id + %{project_id: id} -> id + _ -> nil + end + + authorize(action, project_id, info, do: func.(operation, args, info)) + end + end + + def translation_comment_subscription_authorize(action, func) do + fn _, args, info -> + subscription = + TranslationCommentsSubscription + |> Repo.get(args.id) + |> Repo.preload(translation: [:revision]) + + authorize(action, subscription.translation.revision.project_id, info, do: func.(subscription, args, info)) + end + end + + def collaborator_authorize(action, func) do + fn _, args, info -> + collaborator = + Collaborator + |> Repo.get(args.id) + + authorize(action, collaborator.project_id, info, do: func.(collaborator, args, info)) + end + end + + def integration_authorize(action, func) do + fn _, args, info -> + integration = + Integration + |> Repo.get(args.id) + + authorize(action, integration.project_id, info, do: func.(integration, args, info)) + end + end +end diff --git a/lib/graphql/helpers/fields.ex b/lib/graphql/helpers/fields.ex new file mode 100644 index 00000000..8db98e4c --- /dev/null +++ b/lib/graphql/helpers/fields.ex @@ -0,0 +1,15 @@ +defmodule Accent.GraphQL.Helpers.Fields do + @doc """ + ## Examples + + iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{foo: "alias"}, nil, nil) + {:ok, "alias"} + iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{foo: %{map: "alias"}}, nil, nil) + {:ok, %{map: "alias"}} + iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{}, nil, nil) + {:ok, nil} + """ + def field_alias(field) do + fn root, _, _ -> {:ok, Map.get(root, field)} end + end +end diff --git a/lib/graphql/mutations/collaborator.ex b/lib/graphql/mutations/collaborator.ex new file mode 100644 index 00000000..0c67595a --- /dev/null +++ b/lib/graphql/mutations/collaborator.ex @@ -0,0 +1,30 @@ +defmodule Accent.GraphQL.Mutations.Collaborator do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + alias Accent.GraphQL.Resolvers.Collaborator, as: CollaboratorResolver + + object :collaborator_mutations do + field :create_collaborator, :mutated_collaborator do + arg(:project_id, non_null(:id)) + arg(:role, non_null(:role)) + arg(:email, non_null(:string)) + + resolve(project_authorize(:create_collaborator, &CollaboratorResolver.create/3, :project_id)) + end + + field :update_collaborator, :mutated_collaborator do + arg(:id, non_null(:id)) + arg(:role, non_null(:role)) + + resolve(collaborator_authorize(:update_collaborator, &CollaboratorResolver.update/3)) + end + + field :delete_collaborator, :mutated_collaborator do + arg(:id, non_null(:id)) + + resolve(collaborator_authorize(:delete_collaborator, &CollaboratorResolver.delete/3)) + end + end +end diff --git a/lib/graphql/mutations/comment.ex b/lib/graphql/mutations/comment.ex new file mode 100644 index 00000000..117360ca --- /dev/null +++ b/lib/graphql/mutations/comment.ex @@ -0,0 +1,27 @@ +defmodule Accent.GraphQL.Mutations.Comment do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + object :comment_mutations do + field :create_comment, :mutated_comment do + arg(:id, non_null(:id)) + arg(:text, non_null(:string)) + + resolve(translation_authorize(:create_comment, &Accent.GraphQL.Resolvers.Comment.create/3)) + end + + field :create_translation_comments_subscription, :mutated_translation_comments_subscription do + arg(:translation_id, non_null(:id)) + arg(:user_id, non_null(:id)) + + resolve(translation_authorize(:create_translation_comments_subscription, &Accent.GraphQL.Resolvers.TranslationCommentSubscription.create/3)) + end + + field :delete_translation_comments_subscription, :mutated_translation_comments_subscription do + arg(:id, non_null(:id)) + + resolve(translation_comment_subscription_authorize(:delete_translation_comments_subscription, &Accent.GraphQL.Resolvers.TranslationCommentSubscription.delete/3)) + end + end +end diff --git a/lib/graphql/mutations/document.ex b/lib/graphql/mutations/document.ex new file mode 100644 index 00000000..b71c847a --- /dev/null +++ b/lib/graphql/mutations/document.ex @@ -0,0 +1,13 @@ +defmodule Accent.GraphQL.Mutations.Document do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + object :document_mutations do + field :delete_document, :mutated_document do + arg(:id, non_null(:id)) + + resolve(document_authorize(:delete_document, &Accent.GraphQL.Resolvers.Document.delete/3)) + end + end +end diff --git a/lib/graphql/mutations/integration.ex b/lib/graphql/mutations/integration.ex new file mode 100644 index 00000000..ca7ee60f --- /dev/null +++ b/lib/graphql/mutations/integration.ex @@ -0,0 +1,38 @@ +defmodule Accent.GraphQL.Mutations.Integration do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + alias Accent.GraphQL.Resolvers.Integration, as: IntegrationResolver + + input_object :integration_data_input do + field(:id, :id) + field(:url, non_null(:string)) + end + + object :integration_mutations do + field :create_integration, :mutated_integration do + arg(:project_id, non_null(:id)) + arg(:service, non_null(:integration_service)) + arg(:events, non_null(list_of(non_null(:integration_event)))) + arg(:data, non_null(:integration_data_input)) + + resolve(project_authorize(:create_integration, &IntegrationResolver.create/3, :project_id)) + end + + field :update_integration, :mutated_integration do + arg(:id, non_null(:id)) + arg(:service, :integration_service) + arg(:events, non_null(list_of(non_null(:integration_event)))) + arg(:data, non_null(:integration_data_input)) + + resolve(integration_authorize(:update_integration, &IntegrationResolver.update/3)) + end + + field :delete_integration, :mutated_integration do + arg(:id, non_null(:id)) + + resolve(integration_authorize(:delete_integration, &IntegrationResolver.delete/3)) + end + end +end diff --git a/lib/graphql/mutations/operation.ex b/lib/graphql/mutations/operation.ex new file mode 100644 index 00000000..f5327943 --- /dev/null +++ b/lib/graphql/mutations/operation.ex @@ -0,0 +1,13 @@ +defmodule Accent.GraphQL.Mutations.Operation do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + object :operation_mutations do + field :rollback_operation, :mutated_operation do + arg(:id, non_null(:id)) + + resolve(operation_authorize(:rollback, &Accent.GraphQL.Resolvers.Operation.rollback/3)) + end + end +end diff --git a/lib/graphql/mutations/project.ex b/lib/graphql/mutations/project.ex new file mode 100644 index 00000000..3fd4436f --- /dev/null +++ b/lib/graphql/mutations/project.ex @@ -0,0 +1,30 @@ +defmodule Accent.GraphQL.Mutations.Project do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + alias Accent.GraphQL.Resolvers.Project, as: ProjectResolver + + object :project_mutations do + field :create_project, :mutated_project do + arg(:name, non_null(:string)) + arg(:language_id, non_null(:id)) + + resolve(&Accent.GraphQL.Resolvers.Project.create/3) + end + + field :update_project, :mutated_project do + arg(:id, non_null(:id)) + arg(:name, non_null(:string)) + arg(:is_file_operations_locked, :boolean) + + resolve(project_authorize(:update_project, &ProjectResolver.update/3)) + end + + field :delete_project, :mutated_project do + arg(:id, non_null(:id)) + + resolve(project_authorize(:delete_project, &ProjectResolver.delete/3)) + end + end +end diff --git a/lib/graphql/mutations/revision.ex b/lib/graphql/mutations/revision.ex new file mode 100644 index 00000000..8e21ae7f --- /dev/null +++ b/lib/graphql/mutations/revision.ex @@ -0,0 +1,40 @@ +defmodule Accent.GraphQL.Mutations.Revision do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + alias Accent.GraphQL.Resolvers.Revision, as: RevisionResolver + + object :revision_mutations do + field :create_revision, :mutated_revision do + arg(:project_id, non_null(:id)) + arg(:language_id, non_null(:id)) + + resolve(project_authorize(:create_slave, &RevisionResolver.create/3, :project_id)) + end + + field :delete_revision, :mutated_revision do + arg(:id, non_null(:id)) + + resolve(revision_authorize(:delete_slave, &RevisionResolver.delete/3)) + end + + field :promote_revision_master, :mutated_revision do + arg(:id, non_null(:id)) + + resolve(revision_authorize(:promote_slave, &RevisionResolver.promote_master/3)) + end + + field :correct_all_revision, :mutated_revision do + arg(:id, non_null(:id)) + + resolve(revision_authorize(:correct_all_revision, &RevisionResolver.correct_all/3)) + end + + field :uncorrect_all_revision, :mutated_revision do + arg(:id, non_null(:id)) + + resolve(revision_authorize(:uncorrect_all_revision, &RevisionResolver.uncorrect_all/3)) + end + end +end diff --git a/lib/graphql/mutations/translation.ex b/lib/graphql/mutations/translation.ex new file mode 100644 index 00000000..800604a7 --- /dev/null +++ b/lib/graphql/mutations/translation.ex @@ -0,0 +1,29 @@ +defmodule Accent.GraphQL.Mutations.Translation do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + alias Accent.GraphQL.Resolvers.Translation, as: TranslationResolver + + object :translation_mutations do + field :correct_translation, :mutated_translation do + arg(:id, non_null(:id)) + arg(:text, non_null(:string)) + + resolve(translation_authorize(:correct_translation, &TranslationResolver.correct/3)) + end + + field :uncorrect_translation, :mutated_translation do + arg(:id, non_null(:id)) + + resolve(translation_authorize(:uncorrect_translation, &TranslationResolver.uncorrect/3)) + end + + field :update_translation, :mutated_translation do + arg(:id, non_null(:id)) + arg(:text, :string) + + resolve(translation_authorize(:update_translation, &TranslationResolver.update/3)) + end + end +end diff --git a/lib/graphql/mutations/version.ex b/lib/graphql/mutations/version.ex new file mode 100644 index 00000000..18567ac0 --- /dev/null +++ b/lib/graphql/mutations/version.ex @@ -0,0 +1,23 @@ +defmodule Accent.GraphQL.Mutations.Version do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + object :version_mutations do + field :create_version, :mutated_version do + arg(:project_id, non_null(:id)) + arg(:name, non_null(:string)) + arg(:tag, non_null(:string)) + + resolve(project_authorize(:create_version, &Accent.GraphQL.Resolvers.Version.create/3, :project_id)) + end + + field :update_version, :mutated_version do + arg(:id, non_null(:id)) + arg(:name, non_null(:string)) + arg(:tag, non_null(:string)) + + resolve(version_authorize(:update_version, &Accent.GraphQL.Resolvers.Version.update/3)) + end + end +end diff --git a/lib/graphql/paginated.ex b/lib/graphql/paginated.ex new file mode 100644 index 00000000..0fda4e25 --- /dev/null +++ b/lib/graphql/paginated.ex @@ -0,0 +1,43 @@ +defmodule Accent.GraphQL.Paginated do + defmodule Meta do + @type t :: %__MODULE__{} + + @enforce_keys [:current_page, :total_pages, :total_entries, :next_page, :previous_page] + defstruct current_page: 0, total_entries: 0, total_pages: 0, next_page: nil, previous_page: nil + end + + @type t(list_of_type) :: %__MODULE__{entries: [list_of_type], meta: Meta.t()} + + @enforce_keys [:entries, :meta] + defstruct entries: [], meta: %{} + + use Accessible + + def format(paginated_list) do + %__MODULE__{entries: paginated_list.entries, meta: meta(paginated_list)} + end + + defp meta(%{page_size: page_size, total_entries: total_entries, total_pages: total_pages, page_number: page_number}) do + %Meta{ + current_page: page_number, + total_entries: total_entries, + total_pages: total_pages, + next_page: build_next_page(page_size, total_entries, total_pages, page_number), + previous_page: build_previous_page(page_size, total_entries, total_pages, page_number) + } + end + + defp build_next_page(_page_size, _entries, 1, _page), do: nil + defp build_next_page(_page_size, _entries, pages, page) when page >= pages, do: nil + + defp build_next_page(page_size, entries, _pages, page) do + if page_size * page < entries, do: page + 1 + end + + defp build_previous_page(_page_size, _entries, _pages, 1), do: nil + defp build_previous_page(_page_size, _entries, 1, _page), do: nil + + defp build_previous_page(page_size, entries, _pages, page) do + if page_size * page < entries + page_size, do: page - 1 + end +end diff --git a/lib/graphql/plugins/authorization.ex b/lib/graphql/plugins/authorization.ex new file mode 100644 index 00000000..eb2d7026 --- /dev/null +++ b/lib/graphql/plugins/authorization.ex @@ -0,0 +1,12 @@ +defmodule Accent.GraphQL.Plugins.Authorization do + defmacro authorize(action, id, info, do: do_clause) do + quote do + with current_user when not is_nil(current_user) <- unquote(info).context[:conn].assigns[:current_user], + true <- Canada.Can.can?(current_user, unquote(action), unquote(id)) do + unquote(do_clause) + else + _ -> {:ok, nil} + end + end + end +end diff --git a/lib/graphql/resolvers/access_token.ex b/lib/graphql/resolvers/access_token.ex new file mode 100644 index 00000000..dd77a275 --- /dev/null +++ b/lib/graphql/resolvers/access_token.ex @@ -0,0 +1,26 @@ +defmodule Accent.GraphQL.Resolvers.AccessToken do + import Ecto.Query, only: [from: 2] + + alias Accent.{ + AccessToken, + Plugs.GraphQLContext, + Project, + Repo + } + + @spec show_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil} + def show_project(project, _, _) do + from( + access_token in AccessToken, + inner_join: user in assoc(access_token, :user), + inner_join: collaboration in assoc(user, :collaborations), + where: collaboration.project_id == ^project.id, + where: user.bot == true + ) + |> Repo.one() + |> case do + %AccessToken{token: token} -> {:ok, token} + _ -> {:ok, nil} + end + end +end diff --git a/lib/graphql/resolvers/activity.ex b/lib/graphql/resolvers/activity.ex new file mode 100644 index 00000000..f70a4ebf --- /dev/null +++ b/lib/graphql/resolvers/activity.ex @@ -0,0 +1,52 @@ +defmodule Accent.GraphQL.Resolvers.Activity do + require Ecto.Query + alias Ecto.Query + + alias Accent.Scopes.Operation, as: OperationScope + + alias Accent.{ + GraphQL.Paginated, + Operation, + Plugs.GraphQLContext, + Project, + Repo, + Translation + } + + @spec list_project(Project.t(), map(), GraphQLContext.t()) :: {:ok, Paginated.t(Operation.t())} + def list_project(project, args, _) do + Operation + |> OperationScope.ignore_actions(args[:action], args[:is_batch]) + |> OperationScope.filter_from_user(args[:user_id]) + |> OperationScope.filter_from_batch(args[:is_batch]) + |> OperationScope.filter_from_action(args[:action]) + |> Query.join(:left, [o], r in assoc(o, :revision)) + |> Query.where([o, r], r.project_id == ^project.id or o.project_id == ^project.id) + |> OperationScope.order_last_to_first() + |> Repo.paginate(page: args[:page], page_size: args[:page_size]) + |> Paginated.format() + |> (&{:ok, &1}).() + end + + @spec list_translation(Translation.t(), map(), GraphQLContext.t()) :: {:ok, Paginated.t(Operation.t())} + def list_translation(translation, args, _) do + translation + |> Ecto.assoc(:operations) + |> OperationScope.filter_from_user(args[:user_id]) + |> OperationScope.filter_from_batch(args[:is_batch]) + |> OperationScope.filter_from_action(args[:action]) + |> Query.where([o, _], o.action not in ["update_proposed"]) + |> OperationScope.order_last_to_first() + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end + + @spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Operation.t() | nil} + def show_project(_project, %{id: id}, _info) do + Operation + |> Query.where(id: ^id) + |> Repo.one() + |> (&{:ok, &1}).() + end +end diff --git a/lib/graphql/resolvers/collaborator.ex b/lib/graphql/resolvers/collaborator.ex new file mode 100644 index 00000000..dc119254 --- /dev/null +++ b/lib/graphql/resolvers/collaborator.ex @@ -0,0 +1,64 @@ +defmodule Accent.GraphQL.Resolvers.Collaborator do + alias Accent.{ + Repo, + Project, + Collaborator, + CollaboratorCreator, + CollaboratorUpdater, + Hook, + Plugs.GraphQLContext + } + + @typep collaborator_operation :: {:ok, %{collaborator: Collaborator.t() | nil, errors: [String.t()] | nil}} + + @broadcaster Application.get_env(:accent, :hook_broadcaster) + + @spec create(Project.t(), %{email: String.t(), role: String.t()}, GraphQLContext.t()) :: collaborator_operation + def create(project, %{email: email, role: role}, info) do + params = %{ + "email" => email, + "role" => role, + "project_id" => project.id, + "assigner_id" => info.context[:conn].assigns[:current_user].id + } + + case CollaboratorCreator.create(params) do + {:ok, collaborator} -> + @broadcaster.fanout(%Hook.Context{ + event: "create_collaborator", + project: project, + user: info.context[:conn].assigns[:current_user], + payload: %{ + collaborator: collaborator + } + }) + + {:ok, %{collaborator: collaborator, errors: nil}} + + {:error, _reason} -> + {:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec update(Collaborator.t(), %{role: String.t()}, GraphQLContext.t()) :: collaborator_operation + def update(collaborator, %{role: role}, _info) do + case CollaboratorUpdater.update(collaborator, %{"role" => role}) do + {:ok, collaborator} -> + {:ok, %{collaborator: collaborator, errors: nil}} + + {:error, _reason} -> + {:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec delete(Collaborator.t(), map(), GraphQLContext.t()) :: collaborator_operation + def delete(collaborator, _, _) do + case Repo.delete(collaborator) do + {:ok, _collaborator} -> + {:ok, %{collaborator: collaborator, errors: nil}} + + {:error, _reason} -> + {:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}} + end + end +end diff --git a/lib/graphql/resolvers/comment.ex b/lib/graphql/resolvers/comment.ex new file mode 100644 index 00000000..7a64da3e --- /dev/null +++ b/lib/graphql/resolvers/comment.ex @@ -0,0 +1,66 @@ +defmodule Accent.GraphQL.Resolvers.Comment do + alias Accent.Scopes.Comment, as: CommentScope + + alias Accent.{ + Repo, + Comment, + Translation, + Project, + Hook, + GraphQL.Paginated, + Plugs.GraphQLContext + } + + @typep comment_operation :: {:ok, %{comment: Comment.t() | nil, errors: [String.t()] | nil}} + + @broadcaster Application.get_env(:accent, :hook_broadcaster) + + @spec create(Translation.t(), %{text: String.t()}, GraphQLContext.t()) :: comment_operation + def create(translation, args, info) do + comment_params = %{ + "text" => args.text, + "user_id" => info.context[:conn].assigns[:current_user].id, + "translation_id" => translation.id + } + + changeset = Comment.changeset(%Comment{}, comment_params) + + case Repo.insert(changeset) do + {:ok, comment} -> + comment = Repo.preload(comment, [:user, translation: [revision: :project]]) + + @broadcaster.fanout(%Hook.Context{ + event: "create_comment", + project: comment.translation.revision.project, + user: info.context[:conn].assigns[:current_user], + payload: %{ + comment: comment + } + }) + + {:ok, %{comment: comment, errors: nil}} + + {:error, _reason} -> + {:ok, %{comment: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec list_project(Project.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Comment.t())} + def list_project(project, args, _) do + Comment + |> CommentScope.from_project(project.id) + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end + + @spec list_translation(Translation.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Comment.t())} + def list_translation(translation, args, _) do + translation + |> Ecto.assoc(:comments) + |> CommentScope.default_order() + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end +end diff --git a/lib/graphql/resolvers/document.ex b/lib/graphql/resolvers/document.ex new file mode 100644 index 00000000..86d3a7d0 --- /dev/null +++ b/lib/graphql/resolvers/document.ex @@ -0,0 +1,72 @@ +defmodule Accent.GraphQL.Resolvers.Document do + require Ecto.Query + + alias Accent.{ + Document, + GraphQL.Paginated, + Plugs.GraphQLContext, + Project, + Repo, + TranslationsCounter + } + + alias Accent.Scopes.Document, as: DocumentScope + + alias Movement.Builders.DocumentDelete, as: DocumentDeleteBuilder + alias Movement.Context + alias Movement.Persisters.DocumentDelete, as: DocumentDeletePersister + + @typep document_operation :: {:ok, %{document: Document.t() | nil, errors: [String.t()] | nil}} + + @spec delete(Document.t(), any(), GraphQLContext.t()) :: document_operation + def delete(document, _, info) do + %Context{} + |> Context.assign(:document, document) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> DocumentDeleteBuilder.build() + |> DocumentDeletePersister.persist() + |> case do + {:ok, _} -> + {:ok, %{document: document, errors: nil}} + + {:error, _reason} -> + {:ok, %{document: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Document.t() | nil} + def show_project(project, %{id: id}, _) do + Document + |> DocumentScope.from_project(project.id) + |> Ecto.Query.where(id: ^id) + |> Repo.one() + |> merge_stats() + |> (&{:ok, &1}).() + end + + @spec list_project(Project.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Document.t())} + def list_project(project, args, _) do + Document + |> DocumentScope.from_project(project.id) + |> Ecto.Query.order_by(desc: :updated_at) + |> Repo.paginate(page: args[:page]) + |> update_in([Access.key(:entries)], &merge_stats/1) + |> update_in([Access.key(:entries)], fn entries -> Enum.filter(entries, fn document -> document.translations_count > 0 end) end) + |> Paginated.format() + |> (&{:ok, &1}).() + end + + defp merge_stats(document) when is_map(document) do + counts = TranslationsCounter.from_documents([document]) + + document + |> Document.merge_stats(counts) + end + + defp merge_stats(documents) when is_list(documents) do + counts = TranslationsCounter.from_documents(documents) + + documents + |> Enum.map(&Document.merge_stats(&1, counts)) + end +end diff --git a/lib/graphql/resolvers/document_format.ex b/lib/graphql/resolvers/document_format.ex new file mode 100644 index 00000000..e264c37f --- /dev/null +++ b/lib/graphql/resolvers/document_format.ex @@ -0,0 +1,9 @@ +defmodule Accent.GraphQL.Resolvers.DocumentFormat do + alias Accent.{ + DocumentFormat, + Plugs.GraphQLContext + } + + @spec list(any(), map(), GraphQLContext.t()) :: {:ok, list(DocumentFormat.t())} + def list(_, _, _), do: {:ok, DocumentFormat.all()} +end diff --git a/lib/graphql/resolvers/integration.ex b/lib/graphql/resolvers/integration.ex new file mode 100644 index 00000000..6bc61d4d --- /dev/null +++ b/lib/graphql/resolvers/integration.ex @@ -0,0 +1,40 @@ +defmodule Accent.GraphQL.Resolvers.Integration do + alias Accent.{ + Project, + Integration, + IntegrationManager, + Plugs.GraphQLContext + } + + @typep integration_operation :: {:ok, %{integration: Integration.t() | nil, errors: [String.t()] | nil}} + + @spec create(Project.t(), map(), GraphQLContext.t()) :: integration_operation + def create(project, args, info) do + args = + args + |> Map.put(:project_id, project.id) + |> Map.put(:user_id, info.context[:conn].assigns[:current_user].id) + + resolve(IntegrationManager.create(args)) + end + + @spec update(Integration.t(), map(), GraphQLContext.t()) :: integration_operation + def update(integration, args, _info) do + resolve(IntegrationManager.update(integration, args)) + end + + @spec delete(Integration.t(), map(), GraphQLContext.t()) :: integration_operation + def delete(integration, _args, _info) do + resolve(IntegrationManager.delete(integration)) + end + + defp resolve(result) do + case result do + {:ok, integration} -> + {:ok, %{integration: integration, errors: nil}} + + {:error, _reason} -> + {:ok, %{integration: nil, errors: ["unprocessable_entity"]}} + end + end +end diff --git a/lib/graphql/resolvers/language.ex b/lib/graphql/resolvers/language.ex new file mode 100644 index 00000000..a7c662d9 --- /dev/null +++ b/lib/graphql/resolvers/language.ex @@ -0,0 +1,21 @@ +defmodule Accent.GraphQL.Resolvers.Language do + alias Accent.{ + Repo, + Language, + GraphQL.Paginated, + Plugs.GraphQLContext + } + + alias Accent.Scopes.Language, as: LanguageScope + + @page_size 10 + + @spec list(any(), %{page: number(), query: String.t()}, GraphQLContext.t()) :: {:ok, Paginated.t(Language.t())} + def list(_, args, _) do + Language + |> LanguageScope.from_search(args[:query]) + |> Repo.paginate(page: args[:page], page_size: @page_size) + |> Paginated.format() + |> (&{:ok, &1}).() + end +end diff --git a/lib/graphql/resolvers/operation.ex b/lib/graphql/resolvers/operation.ex new file mode 100644 index 00000000..58d83c05 --- /dev/null +++ b/lib/graphql/resolvers/operation.ex @@ -0,0 +1,24 @@ +defmodule Accent.GraphQL.Resolvers.Operation do + alias Movement.Builders.Rollback, as: RollbackBuilder + alias Movement.Persisters.Rollback, as: RollbackPersister + + alias Accent.{ + Operation, + Plugs.GraphQLContext + } + + @spec rollback(Operation.t(), any(), GraphQLContext.t()) :: {:ok, %{operation: boolean(), errors: [String.t()] | nil}} + def rollback(operation, _, info) do + operation = Accent.Repo.preload(operation, :batch_operation) + + %Movement.Context{} + |> Movement.Context.assign(:operation, operation) + |> Movement.Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> RollbackBuilder.build() + |> RollbackPersister.persist() + |> case do + {:ok, _} -> {:ok, %{operation: true, errors: nil}} + {:error, _} -> {:ok, %{operation: false, errors: ["unprocessable_entity"]}} + end + end +end diff --git a/lib/graphql/resolvers/permission.ex b/lib/graphql/resolvers/permission.ex new file mode 100644 index 00000000..b33110f5 --- /dev/null +++ b/lib/graphql/resolvers/permission.ex @@ -0,0 +1,16 @@ +defmodule Accent.GraphQL.Resolvers.Permission do + alias Accent.{ + Project, + Plugs.GraphQLContext + } + + @spec list_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, [atom()]} + def list_project(project, _, %{context: context}) do + permissions = + context[:conn].assigns[:current_user].permissions + |> Map.get(project.id) + |> Accent.RoleAbilities.actions_for() + + {:ok, permissions} + end +end diff --git a/lib/graphql/resolvers/project.ex b/lib/graphql/resolvers/project.ex new file mode 100644 index 00000000..e117ecee --- /dev/null +++ b/lib/graphql/resolvers/project.ex @@ -0,0 +1,80 @@ +defmodule Accent.GraphQL.Resolvers.Project do + require Ecto.Query + + alias Accent.Scopes.Project, as: ProjectScope + + alias Accent.{ + Repo, + Project, + ProjectCreator, + ProjectUpdater, + ProjectDeleter, + User, + GraphQL.Paginated, + Plugs.GraphQLContext + } + + alias Ecto.Query + + @typep project_operation :: {:ok, %{project: Project.t() | nil, errors: [String.t()] | nil}} + + @spec create(any(), %{name: String.t(), language_id: String.t()}, GraphQLContext.t()) :: project_operation + def create(_, %{name: name, language_id: language_id}, info) do + params = %{ + "name" => name, + "language_id" => language_id + } + + case ProjectCreator.create(params: params, user: info.context[:conn].assigns[:current_user]) do + {:ok, project} -> + {:ok, %{project: project, errors: nil}} + + {:error, _reason} -> + {:ok, %{project: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec delete(Project.t(), any(), GraphQLContext.t()) :: project_operation + def delete(project, _, _) do + {:ok, _} = ProjectDeleter.delete(project: project) + + {:ok, %{project: project, errors: nil}} + end + + @spec update(Project.t(), %{name: String.t(), is_file_operations_locked: boolean() | nil}, GraphQLContext.t()) :: project_operation + def update(project, %{name: name, is_file_operations_locked: locked_file_operations}, info) do + params = %{ + "name" => name, + "locked_file_operations" => locked_file_operations + } + + case ProjectUpdater.update(project: project, params: params, user: info.context[:conn].assigns[:current_user]) do + {:ok, project} -> + {:ok, %{project: project, errors: nil}} + + {:error, _reason} -> + {:ok, %{project: nil, errors: ["unprocessable_entity"]}} + end + end + + def update(project, %{name: name}, info), do: update(project, %{name: name, is_file_operations_locked: nil}, info) + + @spec list_viewer(User.t(), %{query: String.t(), page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Project.t())} + def list_viewer(viewer, args, _info) do + Project + |> Query.join(:inner, [p], c in assoc(p, :collaborators)) + |> Query.where([_, c], c.user_id == ^viewer.id) + |> Query.order_by([p, _], asc: p.name) + |> ProjectScope.from_search(args[:query]) + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end + + @spec show_viewer(any(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Project.t() | nil} + def show_viewer(_, %{id: id}, _) do + Project + |> Repo.get(id) + |> (&{:ok, &1}).() + end +end diff --git a/lib/graphql/resolvers/revision.ex b/lib/graphql/resolvers/revision.ex new file mode 100644 index 00000000..0dc25eb1 --- /dev/null +++ b/lib/graphql/resolvers/revision.ex @@ -0,0 +1,137 @@ +defmodule Accent.GraphQL.Resolvers.Revision do + require Ecto.Query + + alias Accent.Scopes.Revision, as: RevisionScope + + alias Accent.{ + Language, + Plugs.GraphQLContext, + Project, + Repo, + Revision, + TranslationsCounter + } + + alias Movement.Builders.NewSlave, as: NewSlaveBuilder + alias Movement.Builders.RevisionCorrectAll, as: RevisionCorrectAllBuilder + alias Movement.Builders.RevisionUncorrectAll, as: RevisionUncorrectAllBuilder + alias Movement.Context + alias Movement.Persisters.NewSlave, as: NewSlavePersister + alias Movement.Persisters.RevisionCorrectAll, as: RevisionCorrectAllPersister + alias Movement.Persisters.RevisionUncorrectAll, as: RevisionUncorrectAllPersister + + @typep revision_operation :: {:ok, %{revision: Revision.t() | nil, errors: [String.t()] | nil}} + + @spec delete(Revision.t(), any(), GraphQLContext.t()) :: revision_operation + def delete(revision, _, _) do + case Accent.RevisionDeleter.delete(revision: revision) do + {:ok, %{revision: revision}} -> + {:ok, %{revision: revision, errors: nil}} + + {:error, _} -> + {:ok, %{revision: revision, errors: ["unprocessable_entity"]}} + end + end + + @spec promote_master(Revision.t(), any(), GraphQLContext.t()) :: revision_operation + def promote_master(revision, _, _) do + case Accent.RevisionMasterPromoter.promote(revision: revision) do + {:ok, revision} -> + {:ok, %{revision: revision, errors: nil}} + + {:error, _} -> + {:ok, %{revision: revision, errors: ["unprocessable_entity"]}} + end + end + + @spec create(Project.t(), %{language_id: String.t()}, GraphQLContext.t()) :: revision_operation + def create(project, args, info) do + language = Repo.get(Language, args.language_id) + + %Context{} + |> Context.assign(:project, project) + |> Context.assign(:language, language) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> NewSlaveBuilder.build() + |> NewSlavePersister.persist() + |> case do + {:ok, _} -> + revision = + Revision + |> RevisionScope.from_project(project.id) + |> RevisionScope.from_language(language.id) + |> Repo.one!() + + {:ok, %{revision: revision, errors: nil}} + + {:error, _reason} -> + {:ok, %{revision: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec correct_all(Revision.t(), any(), GraphQLContext.t()) :: revision_operation + def correct_all(revision, _, info) do + %Context{} + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> RevisionCorrectAllBuilder.build() + |> RevisionCorrectAllPersister.persist() + |> case do + {:ok, _} -> + {:ok, %{revision: merge_stats(revision), errors: nil}} + + {:error, _reason} -> + {:ok, %{revision: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec uncorrect_all(Revision.t(), any(), GraphQLContext.t()) :: revision_operation + def uncorrect_all(revision, _, info) do + %Context{} + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> RevisionUncorrectAllBuilder.build() + |> RevisionUncorrectAllPersister.persist() + |> case do + {:ok, _} -> + {:ok, %{revision: merge_stats(revision), errors: nil}} + + {:error, _reason} -> + {:ok, %{revision: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Revision.t() | nil} + def show_project(project, %{id: id}, _) do + Revision + |> RevisionScope.from_project(project.id) + |> Ecto.Query.where(id: ^id) + |> Repo.one() + |> merge_stats() + |> (&{:ok, &1}).() + end + + @spec list_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, [Revision.t()]} + def list_project(project, _, _) do + project + |> Ecto.assoc(:revisions) + |> Ecto.Query.order_by(desc: :master, asc: :inserted_at) + |> Repo.all() + |> merge_stats() + |> (&{:ok, &1}).() + end + + defp merge_stats(revision) when is_map(revision) do + counts = TranslationsCounter.from_revisions([revision]) + + revision + |> Revision.merge_stats(counts) + end + + defp merge_stats(revisions) when is_list(revisions) do + counts = TranslationsCounter.from_revisions(revisions) + + revisions + |> Enum.map(&Revision.merge_stats(&1, counts)) + end +end diff --git a/lib/graphql/resolvers/role.ex b/lib/graphql/resolvers/role.ex new file mode 100644 index 00000000..96d552ee --- /dev/null +++ b/lib/graphql/resolvers/role.ex @@ -0,0 +1,9 @@ +defmodule Accent.GraphQL.Resolvers.Role do + alias Accent.{ + Role, + Plugs.GraphQLContext + } + + @spec list(any(), map(), GraphQLContext.t()) :: {:ok, list(Role.t())} + def list(_, _, _), do: {:ok, Role.all()} +end diff --git a/lib/graphql/resolvers/translation.ex b/lib/graphql/resolvers/translation.ex new file mode 100644 index 00000000..263975c7 --- /dev/null +++ b/lib/graphql/resolvers/translation.ex @@ -0,0 +1,161 @@ +defmodule Accent.GraphQL.Resolvers.Translation do + require Ecto.Query + alias Ecto.Query + + alias Accent.Scopes.Translation, as: TranslationScope + + alias Movement.Builders.TranslationCorrectConflict, as: TranslationCorrectConflictBuilder + alias Movement.Builders.TranslationUncorrectConflict, as: TranslationUncorrectConflictBuilder + alias Movement.Builders.TranslationUpdate, as: TranslationUpdateBuilder + alias Movement.Context + alias Movement.Persisters.Base, as: BasePersister + + alias Accent.{ + GraphQL.Paginated, + Plugs.GraphQLContext, + Project, + Repo, + Revision, + Translation + } + + @typep translation_operation :: {:ok, %{translation: Translation.t() | nil, errors: [String.t()] | nil}} + + @spec correct(Translation.t(), %{text: String.t()}, GraphQLContext.t()) :: translation_operation + def correct(translation, %{text: text}, info) do + %Context{} + |> Context.assign(:translation, translation) + |> Context.assign(:text, text) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> TranslationCorrectConflictBuilder.build() + |> (&fn -> BasePersister.execute(&1) end).() + |> Repo.transaction() + |> case do + {:ok, {_context, [translation]}} -> + {:ok, %{translation: translation, errors: nil}} + + {:error, _reason} -> + {:ok, %{translation: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec uncorrect(Translation.t(), map(), GraphQLContext.t()) :: translation_operation + def uncorrect(translation, _, info) do + %Context{} + |> Context.assign(:translation, translation) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> TranslationUncorrectConflictBuilder.build() + |> (&fn -> BasePersister.execute(&1) end).() + |> Repo.transaction() + |> case do + {:ok, {_context, [translation]}} -> + {:ok, %{translation: translation, errors: nil}} + + {:error, _reason} -> + {:ok, %{translation: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec update(Translation.t(), %{text: String.t()}, GraphQLContext.t()) :: translation_operation + def update(translation, %{text: text}, info) do + %Context{} + |> Context.assign(:translation, translation) + |> Context.assign(:text, text) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> TranslationUpdateBuilder.build() + |> (&fn -> BasePersister.execute(&1) end).() + |> Repo.transaction() + |> case do + {:ok, {_context, [translation]}} -> + {:ok, %{translation: translation, errors: nil}} + + {:ok, _} -> + {:ok, %{translation: translation, errors: nil}} + + {:error, _reason} -> + {:ok, %{translation: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Translation.t() | nil} + def show_project(project, %{id: id}, _) do + translation = + Translation + |> TranslationScope.from_project(project.id) + |> Query.where(id: ^id) + |> Repo.one() + |> Repo.preload(:revision) + + {:ok, translation} + end + + @spec list_revision(Revision.t(), map(), GraphQLContext.t()) :: {:ok, Paginated.t(Translation.t())} + def list_revision(revision, args, _) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.from_revision(revision.id) + |> TranslationScope.from_search(args[:query]) + |> TranslationScope.from_document(args[:document] || :all) + |> TranslationScope.parse_order(args[:order]) + |> TranslationScope.parse_conflicted(args[:is_conflicted]) + |> TranslationScope.from_version(args[:version]) + |> Query.preload(:revision) + |> Repo.paginate(page: args[:page]) + + translations = %{translations | entries: add_related_translations(translations.entries, args[:reference_revision], args[:version])} + + {:ok, Paginated.format(translations)} + end + + @spec related_translations(Translation.t(), map(), struct()) :: {:ok, [Translation.t()]} + def related_translations(translation, _, _) do + revision = + translation + |> Ecto.assoc(:revision) + |> Repo.one() + + revision_ids = + Project + |> Repo.get!(revision.project_id) + |> Ecto.assoc(:revisions) + |> Query.select([r], r.id) + |> Repo.all() + + translations = + Translation + |> TranslationScope.from_revisions(revision_ids) + |> TranslationScope.from_key(translation.key) + |> TranslationScope.from_document(translation.document_id) + |> TranslationScope.not_id(translation.id) + |> TranslationScope.from_version(translation.version_id) + |> Repo.all() + + {:ok, translations} + end + + defp add_related_translations(entries, nil, _), do: entries + + defp add_related_translations(entries, reference_revision, version) do + reference_revision = Repo.get(Revision, reference_revision) + + reference_translations = + Translation + |> TranslationScope.active() + |> TranslationScope.from_revision(reference_revision.id) + |> TranslationScope.from_keys(Enum.map(entries, &Map.get(&1, :key))) + |> TranslationScope.from_version(version) + |> Repo.all() + |> Enum.group_by(&Map.get(&1, :key)) + + Enum.map(entries, fn translation -> + case reference_translations[translation.key] do + [reference_translation | _tail] -> + Map.put(translation, :related_translation, reference_translation) + + _ -> + translation + end + end) + end +end diff --git a/lib/graphql/resolvers/translation_comment_subscription.ex b/lib/graphql/resolvers/translation_comment_subscription.ex new file mode 100644 index 00000000..2b2a161d --- /dev/null +++ b/lib/graphql/resolvers/translation_comment_subscription.ex @@ -0,0 +1,42 @@ +defmodule Accent.GraphQL.Resolvers.TranslationCommentSubscription do + alias Accent.{ + Repo, + Translation, + TranslationCommentsSubscription, + Plugs.GraphQLContext + } + + @typep translation_comments_subscription_operation :: {:ok, %{translation_comments_subscription: TranslationCommentsSubscription.t() | nil, errors: [String.t()] | nil}} + + @spec create(Translation.t(), %{user_id: String.t(), translation_id: String.t()}, GraphQLContext.t()) :: translation_comments_subscription_operation + def create(translation, args, _info) do + comment_subscription_params = %{ + "user_id" => args.user_id, + "translation_id" => translation.id + } + + %TranslationCommentsSubscription{} + |> TranslationCommentsSubscription.changeset(comment_subscription_params) + |> Repo.insert() + |> case do + {:ok, subscription} -> + {:ok, %{translation_comments_subscription: subscription, errors: nil}} + + {:error, _reason} -> + {:ok, %{translation_comments_subscription: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec delete(TranslationCommentsSubscription.t(), any(), GraphQLContext.t()) :: translation_comments_subscription_operation + def delete(translation_comments_subscription, _args, _info) do + translation_comments_subscription + |> Repo.delete() + |> case do + {:ok, _} -> + {:ok, %{translation_comments_subscription: nil, errors: nil}} + + {:error, _} -> + {:ok, %{translation_comments_subscription: translation_comments_subscription, errors: ["unprocessable_entity"]}} + end + end +end diff --git a/lib/graphql/resolvers/version.ex b/lib/graphql/resolvers/version.ex new file mode 100644 index 00000000..88dfd330 --- /dev/null +++ b/lib/graphql/resolvers/version.ex @@ -0,0 +1,59 @@ +defmodule Accent.GraphQL.Resolvers.Version do + require Ecto.Query + + alias Accent.{ + GraphQL.Paginated, + Plugs.GraphQLContext, + Project, + Repo, + Version + } + + alias Movement.Context + alias Movement.Builders.NewVersion, as: NewVersionBuilder + alias Movement.Persisters.NewVersion, as: NewVersionPersister + + @typep version_operation :: {:ok, %{version: Version.t() | nil, errors: [String.t()] | nil}} + + @spec create(Project.t(), %{name: String.t(), tag: String.t()}, GraphQLContext.t()) :: version_operation + def create(project, %{name: name, tag: tag}, info) do + %Context{} + |> Context.assign(:project, project) + |> Context.assign(:name, name) + |> Context.assign(:tag, tag) + |> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id) + |> NewVersionBuilder.build() + |> NewVersionPersister.persist() + |> case do + {:ok, {%{assigns: %{version: version}}, _}} -> + {:ok, %{version: version, errors: nil}} + + {:error, _reason} -> + {:ok, %{version: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec update(Version.t(), %{name: String.t(), tag: String.t()}, GraphQLContext.t()) :: version_operation + def update(version, args, _info) do + version + |> Version.changeset(%{name: args[:name], tag: args[:tag]}) + |> Repo.update() + |> case do + {:ok, version} -> + {:ok, %{version: version, errors: nil}} + + {:error, _reason} -> + {:ok, %{version: nil, errors: ["unprocessable_entity"]}} + end + end + + @spec list_project(Project.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Version.t())} + def list_project(project, args, _) do + project + |> Ecto.assoc(:versions) + |> Ecto.Query.order_by(desc: :inserted_at) + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end +end diff --git a/lib/graphql/resolvers/viewer.ex b/lib/graphql/resolvers/viewer.ex new file mode 100644 index 00000000..0e08bdc7 --- /dev/null +++ b/lib/graphql/resolvers/viewer.ex @@ -0,0 +1,11 @@ +defmodule Accent.GraphQL.Resolvers.Viewer do + alias Accent.{ + User, + Plugs.GraphQLContext + } + + @spec show(nil, map(), GraphQLContext.t()) :: {:ok, User.t() | nil} + def show(_, _, %{context: context}) do + {:ok, context[:conn].assigns[:current_user]} + end +end diff --git a/lib/graphql/schema.ex b/lib/graphql/schema.ex new file mode 100644 index 00000000..9dc75f9b --- /dev/null +++ b/lib/graphql/schema.ex @@ -0,0 +1,98 @@ +defmodule Accent.GraphQL.Schema do + use Absinthe.Schema + + alias Accent.Repo + + # Scalars + import_types(Accent.GraphQL.DatetimeScalar) + + # Types + import_types(Accent.GraphQL.Types.DocumentFormat) + import_types(Accent.GraphQL.Types.Role) + import_types(Accent.GraphQL.Types.Viewer) + import_types(Accent.GraphQL.Types.Pagination) + import_types(Accent.GraphQL.Types.User) + import_types(Accent.GraphQL.Types.Translation) + import_types(Accent.GraphQL.Types.Revision) + import_types(Accent.GraphQL.Types.Integration) + import_types(Accent.GraphQL.Types.Project) + import_types(Accent.GraphQL.Types.Activity) + import_types(Accent.GraphQL.Types.Document) + import_types(Accent.GraphQL.Types.Collaborator) + import_types(Accent.GraphQL.Types.Comment) + import_types(Accent.GraphQL.Types.Language) + import_types(Accent.GraphQL.Types.Version) + import_types(Accent.GraphQL.Types.MutationResult) + + query do + field :viewer, :viewer do + resolve(&Accent.GraphQL.Resolvers.Viewer.show/3) + end + + field :languages, non_null(:languages) do + arg(:query, :string) + + resolve(&Accent.GraphQL.Resolvers.Language.list/3) + end + + field :roles, non_null(list_of(non_null(:role_item))) do + resolve(&Accent.GraphQL.Resolvers.Role.list/3) + end + + field :document_formats, non_null(list_of(non_null(:document_format_item))) do + resolve(&Accent.GraphQL.Resolvers.DocumentFormat.list/3) + end + end + + mutation do + # Mutation types + import_types(Accent.GraphQL.Mutations.Translation) + import_types(Accent.GraphQL.Mutations.Comment) + import_types(Accent.GraphQL.Mutations.Collaborator) + import_types(Accent.GraphQL.Mutations.Document) + import_types(Accent.GraphQL.Mutations.Revision) + import_types(Accent.GraphQL.Mutations.Project) + import_types(Accent.GraphQL.Mutations.Integration) + import_types(Accent.GraphQL.Mutations.Operation) + import_types(Accent.GraphQL.Mutations.Version) + + import_fields(:comment_mutations) + import_fields(:translation_mutations) + import_fields(:collaborator_mutations) + import_fields(:document_mutations) + import_fields(:revision_mutations) + import_fields(:project_mutations) + import_fields(:integration_mutations) + import_fields(:operation_mutations) + import_fields(:version_mutations) + end + + def context(absinthe_context) do + default_query = fn queryable, _ -> queryable end + default_source = Dataloader.Ecto.new(Repo, query: default_query) + + loader = + [ + Accent.AccessToken, + Accent.Collaborator, + Accent.Comment, + Accent.Document, + Accent.Integration, + Accent.Language, + Accent.Operation, + Accent.Project, + Accent.Revision, + Accent.Translation, + Accent.TranslationCommentsSubscription, + Accent.User, + Accent.Version + ] + |> Enum.reduce(Dataloader.new(), &Dataloader.add_source(&2, &1, default_source)) + + Map.put(absinthe_context, :loader, loader) + end + + def plugins do + [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() + end +end diff --git a/lib/graphql/types/activity.ex b/lib/graphql/types/activity.ex new file mode 100644 index 00000000..9bbd0dce --- /dev/null +++ b/lib/graphql/types/activity.ex @@ -0,0 +1,98 @@ +defmodule Accent.GraphQL.Types.Activity do + use Absinthe.Schema.Notation + + alias Accent.Repo + alias Accent.GraphQL.Paginated + + import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2] + import Accent.GraphQL.Helpers.Fields + + object :activity_stat do + field(:action, non_null(:string), resolve: field_alias("action")) + field(:count, non_null(:integer), resolve: field_alias("count")) + end + + object :activity_previous_translation do + field :value_type, :translation_value_type do + resolve(fn + %{"value_type" => ""}, _, _ -> {:ok, "string"} + %{"value_type" => nil}, _, _ -> {:ok, "string"} + %{"value_type" => value_type}, _, _ -> {:ok, value_type} + _, _, _ -> {:ok, "empty"} + end) + end + + field(:is_removed, :boolean, resolve: field_alias("removed")) + field(:is_conflicted, :boolean, resolve: field_alias("conflicted")) + field(:proposed_text, :string, resolve: field_alias("proposed_text")) + + field :text, :string do + resolve(fn previous_translation, _, _ -> + {:ok, previous_translation["corrected_text"] || previous_translation["proposed_text"]} + end) + end + + field(:conflicted_text, :string, resolve: field_alias("conflicted_text")) + end + + object :activity do + field(:id, non_null(:id)) + field(:action, non_null(:string)) + field(:is_batch, non_null(:boolean), resolve: field_alias(:batch)) + field(:is_rollbacked, non_null(:boolean), resolve: field_alias(:rollbacked)) + field(:inserted_at, non_null(:datetime)) + field(:updated_at, non_null(:datetime)) + + field( + :activity_type, + :string, + resolve: fn activity, _, _ -> + case activity do + %{translation_id: id} when not is_nil(id) -> {:ok, :translation} + %{revision_id: id} when not is_nil(id) -> {:ok, :revision} + _ -> {:ok, :project} + end + end + ) + + field(:text, :string) + field(:stats, list_of(:activity_stat)) + + field :value_type, non_null(:translation_value_type) do + resolve(fn + %{value_type: ""}, _, _ -> {:ok, "string"} + %{value_type: nil}, _, _ -> {:ok, "string"} + %{value_type: value_type}, _, _ -> {:ok, value_type} + _, _, _ -> {:ok, "empty"} + end) + end + + field :operations, :activities do + arg(:page, :integer) + + resolve(fn activity, args, _ -> + activity + |> Ecto.assoc(:operations) + |> Repo.paginate(page: args[:page]) + |> Paginated.format() + |> (&{:ok, &1}).() + end) + end + + field(:previous_translation, :activity_previous_translation) + field(:batch_operation, :activity, resolve: dataloader(Accent.Operation, :batch_operation)) + field(:rollbacked_operation, :activity, resolve: dataloader(Accent.Operation, :rollbacked_operation)) + field(:rollback_operation, :activity, resolve: dataloader(Accent.Operation, :rollback_operation)) + field(:user, non_null(:user), resolve: dataloader(Accent.User)) + field(:translation, :translation, resolve: dataloader(Accent.Translation)) + field(:revision, :revision, resolve: dataloader(Accent.Revision)) + field(:document, :document, resolve: dataloader(Accent.Document)) + field(:project, :project, resolve: dataloader(Accent.Project)) + field(:version, :version, resolve: dataloader(Accent.Version)) + end + + object :activities do + field(:meta, non_null(:pagination_meta)) + field(:entries, list_of(:activity)) + end +end diff --git a/lib/graphql/types/collaborator.ex b/lib/graphql/types/collaborator.ex new file mode 100644 index 00000000..f96bf053 --- /dev/null +++ b/lib/graphql/types/collaborator.ex @@ -0,0 +1,44 @@ +defmodule Accent.GraphQL.Types.Collaborator do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2] + + interface :collaborator do + field(:id, non_null(:id)) + field(:assigner, :user) + field(:email, :string) + field(:role, :role) + field(:is_pending, :boolean) + field(:inserted_at, non_null(:datetime)) + field(:user, :user) + + resolve_type(fn + %{user_id: nil}, _ -> :pending_collaborator + %{}, _ -> :confirmed_collaborator + end) + end + + object :confirmed_collaborator do + field(:id, non_null(:id)) + field(:user, :user, resolve: dataloader(Accent.User)) + field(:assigner, :user, resolve: dataloader(Accent.User, :assigner)) + field(:email, :string) + field(:role, :role) + field(:is_pending, :boolean, resolve: fn _, _ -> {:ok, false} end) + field(:inserted_at, non_null(:datetime)) + + interface(:collaborator) + end + + object :pending_collaborator do + field(:id, non_null(:id)) + field(:user, :user, resolve: fn _, _ -> {:ok, nil} end) + field(:assigner, :user, resolve: dataloader(Accent.User, :assigner)) + field(:email, :string) + field(:role, :role) + field(:is_pending, :boolean, resolve: fn _, _ -> {:ok, true} end) + field(:inserted_at, non_null(:datetime)) + + interface(:collaborator) + end +end diff --git a/lib/graphql/types/comment.ex b/lib/graphql/types/comment.ex new file mode 100644 index 00000000..5221b1aa --- /dev/null +++ b/lib/graphql/types/comment.ex @@ -0,0 +1,25 @@ +defmodule Accent.GraphQL.Types.Comment do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + + object :comment do + field(:id, non_null(:id)) + field(:text, non_null(:string)) + field(:translation, :translation, resolve: dataloader(Accent.Translation)) + field(:user, :user, resolve: dataloader(Accent.User)) + field(:inserted_at, non_null(:datetime)) + end + + object :comments do + field(:meta, :pagination_meta) + field(:entries, list_of(:comment)) + end + + object :translation_comments_subscription do + field(:id, non_null(:id)) + field(:user, :user, resolve: dataloader(Accent.User)) + field(:translation, :translation, resolve: dataloader(Accent.Translation)) + field(:inserted_at, non_null(:datetime)) + end +end diff --git a/lib/graphql/types/document.ex b/lib/graphql/types/document.ex new file mode 100644 index 00000000..70615f7a --- /dev/null +++ b/lib/graphql/types/document.ex @@ -0,0 +1,17 @@ +defmodule Accent.GraphQL.Types.Document do + use Absinthe.Schema.Notation + + object :documents do + field(:meta, non_null(:pagination_meta)) + field(:entries, list_of(:document)) + end + + object :document do + field(:id, non_null(:id)) + field(:path, non_null(:string)) + field(:format, non_null(:document_format)) + field(:translations_count, non_null(:integer)) + field(:conflicts_count, non_null(:integer)) + field(:reviewed_count, non_null(:integer)) + end +end diff --git a/lib/graphql/types/document_format.ex b/lib/graphql/types/document_format.ex new file mode 100644 index 00000000..0d9be827 --- /dev/null +++ b/lib/graphql/types/document_format.ex @@ -0,0 +1,21 @@ +defmodule Accent.GraphQL.Types.DocumentFormat do + use Absinthe.Schema.Notation + + enum :document_format do + value(:json, as: "json") + value(:simple_json, as: "simple_json") + value(:strings, as: "strings") + value(:gettext, as: "gettext") + value(:rails_yml, as: "rails_yml") + value(:es6_module, as: "es6_module") + value(:android_xml, as: "android_xml") + value(:java_properties, as: "java_properties") + value(:java_properties_xml, as: "java_properties_xml") + end + + object :document_format_item do + field(:name, non_null(:string)) + field(:extension, non_null(:string)) + field(:slug, non_null(:document_format)) + end +end diff --git a/lib/graphql/types/integration.ex b/lib/graphql/types/integration.ex new file mode 100644 index 00000000..b6607828 --- /dev/null +++ b/lib/graphql/types/integration.ex @@ -0,0 +1,24 @@ +defmodule Accent.GraphQL.Types.Integration do + use Absinthe.Schema.Notation + + enum :integration_service do + value(:slack, as: "slack") + end + + enum :integration_event do + value(:sync, as: "sync") + value(:merge, as: "merge") + end + + object :integration do + field(:id, non_null(:id)) + field(:service, non_null(:integration_service)) + field(:events, non_null(list_of(non_null(:integration_event)))) + field(:data, non_null(:integration_data)) + end + + object :integration_data do + field(:id, non_null(:id)) + field(:url, non_null(:string)) + end +end diff --git a/lib/graphql/types/language.ex b/lib/graphql/types/language.ex new file mode 100644 index 00000000..4ab62d44 --- /dev/null +++ b/lib/graphql/types/language.ex @@ -0,0 +1,14 @@ +defmodule Accent.GraphQL.Types.Language do + use Absinthe.Schema.Notation + + object :language do + field(:id, non_null(:id)) + field(:slug, non_null(:id)) + field(:name, non_null(:string)) + end + + object :languages do + field(:meta, non_null(:pagination_meta)) + field(:entries, non_null(list_of(non_null(:language)))) + end +end diff --git a/lib/graphql/types/mutation_result.ex b/lib/graphql/types/mutation_result.ex new file mode 100644 index 00000000..af05d6fc --- /dev/null +++ b/lib/graphql/types/mutation_result.ex @@ -0,0 +1,53 @@ +defmodule Accent.GraphQL.Types.MutationResult do + use Absinthe.Schema.Notation + + object :mutated_translation do + field(:translation, :translation) + field(:errors, list_of(:string)) + end + + object :mutated_project do + field(:project, :project) + field(:errors, list_of(:string)) + end + + object :mutated_version do + field(:version, :version) + field(:errors, list_of(:string)) + end + + object :mutated_revision do + field(:revision, :revision) + field(:errors, list_of(:string)) + end + + object :mutated_collaborator do + field(:collaborator, :collaborator) + field(:errors, list_of(:string)) + end + + object :mutated_integration do + field(:integration, :integration) + field(:errors, list_of(:string)) + end + + object :mutated_comment do + field(:comment, :comment) + field(:errors, list_of(:string)) + end + + object :mutated_document do + field(:document, :document) + field(:errors, list_of(:string)) + end + + object :mutated_operation do + field(:operation, :boolean) + field(:errors, list_of(:string)) + end + + object :mutated_translation_comments_subscription do + field(:translation_comments_subscription, :translation_comments_subscription) + field(:errors, list_of(:string)) + end +end diff --git a/lib/graphql/types/pagination.ex b/lib/graphql/types/pagination.ex new file mode 100644 index 00000000..2ee31ada --- /dev/null +++ b/lib/graphql/types/pagination.ex @@ -0,0 +1,11 @@ +defmodule Accent.GraphQL.Types.Pagination do + use Absinthe.Schema.Notation + + object :pagination_meta do + field(:current_page, non_null(:integer)) + field(:previous_page, :integer) + field(:next_page, :integer) + field(:total_entries, non_null(:integer)) + field(:total_pages, non_null(:integer)) + end +end diff --git a/lib/graphql/types/project.ex b/lib/graphql/types/project.ex new file mode 100644 index 00000000..6a641663 --- /dev/null +++ b/lib/graphql/types/project.ex @@ -0,0 +1,91 @@ +defmodule Accent.GraphQL.Types.Project do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + import Accent.GraphQL.Helpers.Fields + import Accent.GraphQL.Helpers.Authorization + + object :projects do + field(:meta, non_null(:pagination_meta)) + field(:entries, list_of(:project)) + end + + object :project do + field(:id, :id) + field(:name, :string) + field(:last_synced_at, :datetime) + field(:is_file_operations_locked, non_null(:boolean), resolve: field_alias(:locked_file_operations)) + + field :access_token, :string do + resolve(project_authorize(:show_project_access_token, &Accent.GraphQL.Resolvers.AccessToken.show_project/3)) + end + + field :viewer_permissions, list_of(:string) do + resolve(project_authorize(:index_permissions, &Accent.GraphQL.Resolvers.Permission.list_project/3)) + end + + field :collaborators, list_of(:collaborator) do + resolve(project_authorize(:index_collaborators, dataloader(Accent.Collaborator))) + end + + field(:language, :language, resolve: dataloader(Accent.Language)) + field(:integrations, list_of(:integration), resolve: dataloader(Accent.Integration)) + + field :document, :document do + arg(:id, non_null(:id)) + + resolve(project_authorize(:show_document, &Accent.GraphQL.Resolvers.Document.show_project/3)) + end + + field :documents, :documents do + arg(:page, :integer) + + resolve(project_authorize(:index_documents, &Accent.GraphQL.Resolvers.Document.list_project/3)) + end + + field :activities, :activities do + arg(:page, :integer) + arg(:page_size, :integer) + arg(:action, :string) + arg(:is_batch, :boolean) + arg(:user_id, :id) + + resolve(project_authorize(:index_project_activities, &Accent.GraphQL.Resolvers.Activity.list_project/3)) + end + + field :comments, :comments do + arg(:page, :integer) + + resolve(project_authorize(:index_comments, &Accent.GraphQL.Resolvers.Comment.list_project/3)) + end + + field :translation, :translation do + arg(:id, non_null(:id)) + + resolve(project_authorize(:show_translation, &Accent.GraphQL.Resolvers.Translation.show_project/3)) + end + + field :activity, :activity do + arg(:id, non_null(:id)) + + resolve(project_authorize(:show_activity, &Accent.GraphQL.Resolvers.Activity.show_project/3)) + end + + field :revision, :revision do + arg(:id, non_null(:id)) + + resolve(project_authorize(:show_revision, &Accent.GraphQL.Resolvers.Revision.show_project/3)) + end + + field :revisions, list_of(:revision) do + resolve(project_authorize(:index_revisions, &Accent.GraphQL.Resolvers.Revision.list_project/3)) + end + + field :versions, :versions do + arg(:page, :integer) + arg(:page_size, :integer) + + resolve(project_authorize(:index_versions, &Accent.GraphQL.Resolvers.Version.list_project/3)) + end + end +end diff --git a/lib/graphql/types/revision.ex b/lib/graphql/types/revision.ex new file mode 100644 index 00000000..7395b71f --- /dev/null +++ b/lib/graphql/types/revision.ex @@ -0,0 +1,31 @@ +defmodule Accent.GraphQL.Types.Revision do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + import Accent.GraphQL.Helpers.Authorization + import Accent.GraphQL.Helpers.Fields + + object :revision do + field(:id, :id) + field(:name, non_null(:string)) + field(:is_master, non_null(:boolean), resolve: field_alias(:master)) + field(:translations_count, non_null(:integer)) + field(:conflicts_count, non_null(:integer)) + field(:reviewed_count, non_null(:integer)) + field(:inserted_at, non_null(:datetime)) + + field(:language, non_null(:language), resolve: dataloader(Accent.Language)) + + field :translations, non_null(:translations) do + arg(:page, :integer) + arg(:order, :string) + arg(:document, :id) + arg(:version, :id) + arg(:query, :string) + arg(:is_conflicted, :boolean) + arg(:reference_revision, :id) + + resolve(revision_authorize(:index_translations, &Accent.GraphQL.Resolvers.Translation.list_revision/3)) + end + end +end diff --git a/lib/graphql/types/role.ex b/lib/graphql/types/role.ex new file mode 100644 index 00000000..73b9b5bb --- /dev/null +++ b/lib/graphql/types/role.ex @@ -0,0 +1,15 @@ +defmodule Accent.GraphQL.Types.Role do + use Absinthe.Schema.Notation + + enum :role do + value(:bot, as: "bot") + value(:owner, as: "owner") + value(:admin, as: "admin") + value(:developer, as: "developer") + value(:reviewer, as: "reviewer") + end + + object :role_item do + field(:slug, non_null(:role)) + end +end diff --git a/lib/graphql/types/translation.ex b/lib/graphql/types/translation.ex new file mode 100644 index 00000000..e072fb58 --- /dev/null +++ b/lib/graphql/types/translation.ex @@ -0,0 +1,77 @@ +defmodule Accent.GraphQL.Types.Translation do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2] + import Accent.GraphQL.Helpers.Authorization + import Accent.GraphQL.Helpers.Fields + + enum :translation_value_type do + value(:string, as: "string") + value(:plural, as: "plural") + value(:boolean, as: "boolean") + value(:null, as: "null") + value(:array, as: "array") + value(:empty, as: "empty") + value(:integer, as: "integer") + end + + object :translation do + field(:id, non_null(:id)) + + field :key, non_null(:string) do + resolve(fn %{key: key}, _, _ -> + {:ok, String.replace(key, ~r/__KEY__(\d)/, "[\\1]")} + end) + end + + field :value_type, non_null(:translation_value_type) do + resolve(fn + %{value_type: ""}, _, _ -> {:ok, "string"} + %{value_type: nil}, _, _ -> {:ok, "string"} + %{value_type: value_type}, _, _ -> {:ok, value_type} + end) + end + + field(:proposed_text, :string) + field(:corrected_text, :string) + field(:conflicted_text, :string) + field(:is_conflicted, non_null(:boolean), resolve: field_alias(:conflicted)) + field(:is_removed, non_null(:boolean), resolve: field_alias(:removed)) + field(:related_translation, :translation) + field(:comments_count, non_null(:integer)) + field(:updated_at, non_null(:datetime)) + + field(:document, :document, resolve: dataloader(Accent.Document)) + field(:revision, :revision, resolve: dataloader(Accent.Revision)) + field(:version, :version, resolve: dataloader(Accent.Version)) + field(:source_translation, :translation, resolve: dataloader(Accent.Translation, :source_translation)) + + field :comments_subscriptions, list_of(:translation_comments_subscription) do + resolve(translation_authorize(:index_translation_comments_subscriptions, dataloader(Accent.TranslationCommentsSubscription, :comments_subscriptions))) + end + + field :comments, :comments do + arg(:page, :integer) + + resolve(translation_authorize(:index_comments, &Accent.GraphQL.Resolvers.Comment.list_translation/3)) + end + + field :activities, :activities do + arg(:page, :integer) + arg(:action, :string) + arg(:is_batch, :boolean) + arg(:user_id, :id) + + resolve(translation_authorize(:index_translation_activities, &Accent.GraphQL.Resolvers.Activity.list_translation/3)) + end + + field :related_translations, list_of(:translation) do + resolve(translation_authorize(:index_translation_activities, &Accent.GraphQL.Resolvers.Translation.related_translations/3)) + end + end + + object :translations do + field(:meta, :pagination_meta) + field(:entries, list_of(:translation)) + end +end diff --git a/lib/graphql/types/user.ex b/lib/graphql/types/user.ex new file mode 100644 index 00000000..001defcc --- /dev/null +++ b/lib/graphql/types/user.ex @@ -0,0 +1,18 @@ +defmodule Accent.GraphQL.Types.User do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Fields + + alias Accent.User + + object :user do + field(:id, non_null(:id)) + field(:is_bot, non_null(:boolean), resolve: field_alias(:bot)) + field(:email, :string) + field(:picture_url, :string) + + field :fullname, non_null(:string) do + resolve(fn user, _, _ -> {:ok, User.name_with_fallback(user)} end) + end + end +end diff --git a/lib/graphql/types/version.ex b/lib/graphql/types/version.ex new file mode 100644 index 00000000..2b66244c --- /dev/null +++ b/lib/graphql/types/version.ex @@ -0,0 +1,19 @@ +defmodule Accent.GraphQL.Types.Version do + use Absinthe.Schema.Notation + + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + + object :version do + field(:id, non_null(:id)) + field(:name, non_null(:string)) + field(:tag, non_null(:string)) + field(:project, :project, resolve: dataloader(Accent.Project)) + field(:user, :user, resolve: dataloader(Accent.User)) + field(:inserted_at, non_null(:datetime)) + end + + object :versions do + field(:meta, :pagination_meta) + field(:entries, list_of(:version)) + end +end diff --git a/lib/graphql/types/viewer.ex b/lib/graphql/types/viewer.ex new file mode 100644 index 00000000..0c79852a --- /dev/null +++ b/lib/graphql/types/viewer.ex @@ -0,0 +1,22 @@ +defmodule Accent.GraphQL.Types.Viewer do + use Absinthe.Schema.Notation + + import Accent.GraphQL.Helpers.Authorization + + object :viewer do + field(:user, :user) + + field :projects, :projects do + arg(:page, :integer) + arg(:query, :string) + + resolve(viewer_authorize(:index_projects, &Accent.GraphQL.Resolvers.Project.list_viewer/3)) + end + + field :project, :project do + arg(:id, non_null(:id)) + + resolve(project_authorize(:show_project, &Accent.GraphQL.Resolvers.Project.show_viewer/3)) + end + end +end diff --git a/lib/hook/broadcaster.ex b/lib/hook/broadcaster.ex new file mode 100644 index 00000000..4dd58f92 --- /dev/null +++ b/lib/hook/broadcaster.ex @@ -0,0 +1,15 @@ +defmodule Accent.Hook.Broadcaster do + @producers [ + Accent.Hook.Producers.Email, + Accent.Hook.Producers.Websocket, + Accent.Hook.Producers.Slack + ] + + @callback fanout(Accent.Hook.Context.t()) :: no_return() + + def fanout(context = %Accent.Hook.Context{}, timeout \\ 5000) do + for producer <- @producers do + GenStage.call(producer, {:notify, context}, timeout) + end + end +end diff --git a/lib/hook/consumers/email.ex b/lib/hook/consumers/email.ex new file mode 100644 index 00000000..6441b656 --- /dev/null +++ b/lib/hook/consumers/email.ex @@ -0,0 +1,50 @@ +defmodule Accent.Hook.Consumers.Email do + use Accent.Hook.EventConsumer, subscribe_to: [Accent.Hook.Producers.Email] + + alias Accent.{ + Repo, + Mailer, + ProjectInviteEmail, + CreateCommentEmail, + Hook + } + + @supported_events ~w(create_collaborator create_comment) + + def handle_events(events, _from, state) do + events + |> Enum.filter(&filter_event/1) + |> Enum.each(fn event -> + event + |> fetch_emails() + |> build_email(event) + |> Mailer.deliver_later() + end) + + {:noreply, [], state} + end + + defp filter_event(%Hook.Context{event: event}), do: event in @supported_events + + defp build_email(emails, %Hook.Context{event: "create_collaborator", project: project, user: user}) do + ProjectInviteEmail.create(emails, user, project) + end + + defp build_email(emails, %Hook.Context{event: "create_comment", payload: payload}) do + CreateCommentEmail.create(emails, payload[:comment]) + end + + defp fetch_emails(%Hook.Context{event: "create_collaborator", payload: payload}) do + [payload[:collaborator].email] + end + + defp fetch_emails(%Hook.Context{event: "create_comment", payload: payload, user: context_user}) do + payload[:comment] + |> Map.get(:translation) + |> Repo.preload(comments_subscriptions: :user) + |> Map.get(:comments_subscriptions) + |> Enum.map(& &1.user) + |> Enum.filter(fn user -> user.id !== context_user.id end) + |> Enum.map(& &1.email) + end +end diff --git a/lib/hook/consumers/slack.ex b/lib/hook/consumers/slack.ex new file mode 100644 index 00000000..ca3638cb --- /dev/null +++ b/lib/hook/consumers/slack.ex @@ -0,0 +1,64 @@ +defmodule Accent.Hook.Consumers.Slack do + use Accent.Hook.EventConsumer, subscribe_to: [Accent.Hook.Producers.Slack] + + require Ecto.Query + + alias Accent.{Repo, Hook} + + @headers [{"Content-Type", "application/json"}] + @service "slack" + @supported_events ~w(sync) + + def handle_events(events, _from, state) do + events + |> Enum.filter(&filter_event/1) + |> Enum.each(fn event -> + handle_event(event, state) + end) + + {:noreply, [], state} + end + + defp filter_event(%Hook.Context{event: event}), do: event in @supported_events + + defp handle_event(context = %Hook.Context{event: event, project: project}, {:http_client, http_client}) do + with integrations <- filter_service_integration_events(project, event, @service), + urls <- Enum.map(integrations, fn integration -> integration.data.url end), + body <- build_body(context) do + post_urls(http_client, urls, body) + end + end + + defp build_body(%Hook.Context{event: "sync", user: user, payload: %{document_path: document_path, batch_operation_stats: stats}}) do + %{ + text: """ + *#{user.fullname}* just synced a file: _#{document_path}_ + + *Stats:* + #{build_stats(stats)} + """ + } + end + + defp build_stats(stats) do + Enum.reduce(stats, "", fn %{action: action, count: count}, acc -> + "#{acc}#{action}: _#{count}_\n" + end) + end + + defp filter_service_integration_events(project, event, service) do + project + |> Ecto.assoc(:integrations) + |> Ecto.Query.where(service: ^service) + |> Repo.all() + |> Enum.filter(fn integration -> event in integration.events end) + end + + defp post_urls(http_client, urls, body) do + for url <- urls do + http_client.post(url, Poison.encode!(body), @headers) + end + + :ok + end +end diff --git a/lib/hook/consumers/websocket.ex b/lib/hook/consumers/websocket.ex new file mode 100644 index 00000000..ea995e48 --- /dev/null +++ b/lib/hook/consumers/websocket.ex @@ -0,0 +1,69 @@ +defmodule Accent.Hook.Consumers.Websocket do + use Accent.Hook.EventConsumer, subscribe_to: [Accent.Hook.Producers.Websocket] + + alias Accent.{ + Endpoint, + Hook + } + + @channel "projects:" + @supported_events ~w(sync create_collaborator create_comment) + + def handle_events(events, _from, state) do + events + |> Enum.filter(&filter_event/1) + |> Enum.each(fn event -> + event + |> serialize_payload() + |> merge_user() + |> broadcast_event() + end) + + {:noreply, [], state} + end + + defp filter_event(%Hook.Context{event: event}), do: event in @supported_events + + defp broadcast_event(event) do + Endpoint.broadcast(@channel <> event.project.id, event.event, event.payload) + end + + defp merge_user(event = %Hook.Context{user: user, payload: payload}) do + new_payload = + Map.merge(payload, %{ + user: %{ + id: user.id, + name: name_with_fallback(user) + } + }) + + Map.put(event, :payload, new_payload) + end + + defp name_with_fallback(%{fullname: nil, email: email}), do: email + defp name_with_fallback(%{fullname: fullname}), do: fullname + + defp serialize_payload(event = %Hook.Context{event: "create_comment", payload: %{comment: comment}}) do + Map.put(event, :payload, %{ + comment: %{ + text: comment.text, + user: %{email: comment.user.email}, + translation: %{id: comment.translation.id, key: comment.translation.key} + } + }) + end + + defp serialize_payload(event = %Hook.Context{event: "create_collaborator", payload: %{collaborator: collaborator}}) do + Map.put(event, :payload, %{ + collaborator: %{ + email: collaborator.email + } + }) + end + + defp serialize_payload(event = %Hook.Context{event: "sync", payload: %{document_path: document_path}}) do + Map.put(event, :payload, %{ + document_path: document_path + }) + end +end diff --git a/lib/hook/context.ex b/lib/hook/context.ex new file mode 100644 index 00000000..16f9cfdd --- /dev/null +++ b/lib/hook/context.ex @@ -0,0 +1,7 @@ +defmodule Accent.Hook.Context do + alias Accent.{User, Project} + + defstruct project: %Project{}, event: "", user: %User{}, payload: %{} + + @type t :: %__MODULE__{} +end diff --git a/lib/hook/event_consumer.ex b/lib/hook/event_consumer.ex new file mode 100644 index 00000000..0a4f7267 --- /dev/null +++ b/lib/hook/event_consumer.ex @@ -0,0 +1,14 @@ +defmodule Accent.Hook.EventConsumer do + defmacro __using__(opts) do + quote do + use GenStage + + def start_link, do: GenStage.start_link(__MODULE__, :ok) + def start_link(state), do: GenStage.start_link(__MODULE__, state) + + def init(state) do + {:consumer, state, unquote(opts)} + end + end + end +end diff --git a/lib/hook/event_producer.ex b/lib/hook/event_producer.ex new file mode 100644 index 00000000..b49ee768 --- /dev/null +++ b/lib/hook/event_producer.ex @@ -0,0 +1,33 @@ +defmodule Accent.Hook.EventProducer do + defmacro __using__(_opts) do + quote do + use GenStage + + def start_link do + GenStage.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(:ok) do + {:producer, {:queue.new(), 0}, dispatcher: GenStage.BroadcastDispatcher} + end + + def handle_call({:notify, event}, from, {queue, demand}) do + dispatch_events(:queue.in({from, event}, queue), demand, []) + end + + def handle_demand(incoming_demand, {queue, demand}) do + dispatch_events(queue, incoming_demand + demand, []) + end + + defp dispatch_events(queue, demand, events) do + with d when d > 0 <- demand, + {{:value, {from, event}}, queue} <- :queue.out(queue) do + GenStage.reply(from, :ok) + dispatch_events(queue, demand - 1, [event | events]) + else + _ -> {:noreply, Enum.reverse(events), {queue, demand}} + end + end + end + end +end diff --git a/lib/hook/producers/email.ex b/lib/hook/producers/email.ex new file mode 100644 index 00000000..08356fb6 --- /dev/null +++ b/lib/hook/producers/email.ex @@ -0,0 +1,3 @@ +defmodule Accent.Hook.Producers.Email do + use Accent.Hook.EventProducer +end diff --git a/lib/hook/producers/slack.ex b/lib/hook/producers/slack.ex new file mode 100644 index 00000000..2e051b1a --- /dev/null +++ b/lib/hook/producers/slack.ex @@ -0,0 +1,3 @@ +defmodule Accent.Hook.Producers.Slack do + use Accent.Hook.EventProducer +end diff --git a/lib/hook/producers/websocket.ex b/lib/hook/producers/websocket.ex new file mode 100644 index 00000000..baa32a58 --- /dev/null +++ b/lib/hook/producers/websocket.ex @@ -0,0 +1,3 @@ +defmodule Accent.Hook.Producers.Websocket do + use Accent.Hook.EventProducer +end diff --git a/lib/langue/entry.ex b/lib/langue/entry.ex new file mode 100644 index 00000000..7bb6df00 --- /dev/null +++ b/lib/langue/entry.ex @@ -0,0 +1,3 @@ +defmodule Langue.Entry do + defstruct key: nil, value: nil, comment: nil, index: 0, value_type: nil +end diff --git a/lib/langue/formatter/android/parser.ex b/lib/langue/formatter/android/parser.ex new file mode 100644 index 00000000..1a703b4c --- /dev/null +++ b/lib/langue/formatter/android/parser.ex @@ -0,0 +1,107 @@ +defmodule Langue.Formatter.Android.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Entry + + def parse(%{render: render}) do + case :mochiweb_html.parse(render) do + {"resources", _options, strings} -> + entries = + strings + |> Enum.reduce(%{comment: [], entries: [], index: 1}, &parse_line(&1, &2)) + |> Map.get(:entries) + + %Langue.Formatter.ParserResult{entries: entries} + + _ -> + Langue.Formatter.ParserResult.empty() + end + end + + # Simple string element, key => value + defp parse_line({"string", attributes, [value]}, acc) do + [key] = for {k, v} <- attributes, k == "name", do: v + + acc + |> Map.put( + :entries, + Enum.concat(acc.entries, [ + %Entry{ + value: sanitize_value_to_string(value), + key: key, + index: acc.index, + comment: Enum.join(acc.comment, "\n") + } + ]) + ) + |> Map.put(:comment, []) + |> Map.put(:index, acc.index + 1) + end + + defp parse_line({"string", attributes, []}, acc) do + [key] = for {k, v} <- attributes, k == "name", do: v + + acc + |> Map.put( + :entries, + Enum.concat(acc.entries, [ + %Entry{ + value: sanitize_value_to_string(""), + value_type: "empty", + key: key, + index: acc.index, + comment: Enum.join(acc.comment, "\n") + } + ]) + ) + |> Map.put(:comment, []) + |> Map.put(:index, acc.index + 1) + end + + # string-array element contains sub elements which are identified by index + defp parse_line({"string-array", attributes, items}, acc) do + [key] = for {k, v} <- attributes, k == "name", do: v + + items + |> Enum.with_index(0) + |> Enum.reduce(acc, &parse_item_line(&1, &2, key)) + end + + # Comments are only appended in the comments key of the accumulator + defp parse_line({:comment, comment}, acc) do + acc + |> Map.put(:comment, Enum.concat(acc.comment, [comment])) + end + + # Unsupported elements are simply ignored + defp parse_line(_, acc), do: acc + + # Item contained in a entry with a value_type array + defp parse_item_line({{"item", _attributes, [value]}, index}, acc, key) do + acc + |> Map.put( + :entries, + Enum.concat(acc.entries, [ + %Entry{ + key: "#{key}.__KEY__#{index}", + value: sanitize_value_to_string(value), + index: acc.index, + comment: Enum.join(acc.comment, "\n"), + value_type: "array" + } + ]) + ) + |> Map.put(:comment, []) + |> Map.put(:index, acc.index + 1) + end + + defp sanitize_value_to_string(value) do + value + |> String.replace("%s", "%@") + |> String.replace(~r/%(\d)\$s/, "%\\g{1}$@") + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\\'", "'") + end +end diff --git a/lib/langue/formatter/android/serializer.ex b/lib/langue/formatter/android/serializer.ex new file mode 100644 index 00000000..02704cf0 --- /dev/null +++ b/lib/langue/formatter/android/serializer.ex @@ -0,0 +1,81 @@ +defmodule Langue.Formatter.Android.Serializer do + @behaviour Langue.Formatter.Serializer + + @xml_template """ + + """ + + def serialize(%{entries: entries}) do + resources = + entries + |> Enum.reduce(%{current_array: [], lines: [], current_array_key: nil}, &parse_line/2) + |> maybe_add_array_items + |> Map.get(:lines) + + resource_render = {"resources", [], resources} |> :mochiweb_html.to_html() |> Enum.join("") + + render = @xml_template <> resource_render + + render = + render + |> String.replace("", "\n") + |> String.replace("", "\n") + |> String.replace("", "-->\n") + |> String.replace(" String.replace(" String.replace(" String.replace("", "\n") + |> String.replace("", "\n \n") + + %Langue.Formatter.SerializerResult{render: render} + end + + defp parse_line(%{key: key, value: value, value_type: "array"}, acc) do + acc + |> add_array_item(key, value) + end + + defp parse_line(%{key: key, value: value, comment: comment}, acc) when is_nil(comment) or comment === "" do + acc + |> maybe_add_array_items + |> add_string(key, value) + end + + defp parse_line(%{key: key, value: value, comment: comment}, acc) do + acc + |> maybe_add_array_items + |> add_comment(comment) + |> add_string(key, value) + end + + defp xml_element(item_name, attributes, value) when is_list(value), do: {item_name, attributes, value} + defp xml_element(item_name, attributes, value), do: {item_name, attributes, sanitize_string_to_value(value)} + + defp sanitize_string_to_value(value) do + value + |> String.replace("%@", "%s") + |> String.replace(~r/%(\d)\$\@/, "%\\g{1}$s") + |> String.replace("'", "\\'") + end + + defp add_comment(acc, comment), do: Map.put(acc, :lines, Enum.concat(acc.lines, [{:comment, comment}])) + defp add_string(acc, key, value), do: Map.put(acc, :lines, Enum.concat(acc.lines, [xml_element("string", [{"name", key}], value)])) + + defp add_array_item(acc, key, value) do + acc + |> Map.put(:current_array, Enum.concat(acc[:current_array], [xml_element("item", [], value)])) + |> Map.put(:current_array_key, acc.current_array_key || key) + end + + defp maybe_add_array_items(acc = %{current_array: array}) when array == [], do: acc + + defp maybe_add_array_items(acc) do + key = String.replace(acc[:current_array_key], ".__KEY__0", "") + + acc + |> Map.put(:current_array_key, nil) + |> Map.put(:current_array, []) + |> Map.put(:lines, Enum.concat(acc[:lines], [xml_element("string-array", [{"name", key}], acc[:current_array])])) + end +end diff --git a/lib/langue/formatter/es6_module/parser.ex b/lib/langue/formatter/es6_module/parser.ex new file mode 100644 index 00000000..cac291e1 --- /dev/null +++ b/lib/langue/formatter/es6_module/parser.ex @@ -0,0 +1,28 @@ +defmodule Langue.Formatter.Es6Module.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Formatter.Json.Parser, as: JsonParser + + def parse(%{render: render}) do + # Remove the first "export default" line + # Reverse the list to have quick access to bottom of the file + # Remove the last 2 lines (the line-break at the end and the closing "}" with the ";") + # Add the first "{" removed in the "export default" + # Add the last "{" removed in the "export default" + # Put back in String + entries = + render + |> String.split("\n") + |> tl + |> Enum.reverse() + |> tl + |> tl + |> Kernel.++(["{"]) + |> Enum.reverse() + |> Kernel.++(["}"]) + |> Enum.join("\n") + |> JsonParser.parse_json() + + %Langue.Formatter.ParserResult{entries: entries} + end +end diff --git a/lib/langue/formatter/es6_module/serializer.ex b/lib/langue/formatter/es6_module/serializer.ex new file mode 100644 index 00000000..2a2b5339 --- /dev/null +++ b/lib/langue/formatter/es6_module/serializer.ex @@ -0,0 +1,12 @@ +defmodule Langue.Formatter.Es6Module.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Formatter.Json.Serializer, as: JsonSerializer + + def serialize(%{entries: entries}) do + content = JsonSerializer.serialize_json(entries) + render = "export default " <> content <> ";\n" + + %Langue.Formatter.SerializerResult{render: render} + end +end diff --git a/lib/langue/formatter/gettext/parser.ex b/lib/langue/formatter/gettext/parser.ex new file mode 100644 index 00000000..545a5e90 --- /dev/null +++ b/lib/langue/formatter/gettext/parser.ex @@ -0,0 +1,64 @@ +defmodule Langue.Formatter.Gettext.Parser do + @behaviour Langue.Formatter.Parser + + @plural_value_type "plural" + + alias Langue.Entry + + def parse(%{render: render}) do + {:ok, po} = Gettext.PO.parse_string(render) + entries = parse_translations(po) + top_of_the_file_comment = join_string(po.top_of_the_file_comments) + header = join_string(po.headers) + + %Langue.Formatter.ParserResult{ + entries: entries, + top_of_the_file_comment: top_of_the_file_comment, + header: header + } + end + + defp parse_translations(%{translations: translations}) do + translations + |> Enum.with_index(1) + |> Enum.flat_map(&parse_translation/1) + end + + defp parse_translation({translation = %{msgid_plural: _}, index}) do + plural_entry = %Entry{ + index: index, + comment: join_string(translation.comments), + key: join_string(translation.msgid) <> key_suffix("_"), + value: join_string(translation.msgid_plural), + value_type: @plural_value_type + } + + translation.msgstr + |> Enum.reduce([plural_entry], fn {plural_index, value}, acc -> + Enum.concat(acc, [ + %Entry{ + index: index, + key: join_string(translation.msgid) <> key_suffix(plural_index), + value: join_string(value), + value_type: @plural_value_type + } + ]) + end) + end + + defp parse_translation({translation, index}) do + [ + %Entry{ + index: index, + comment: join_string(translation.comments), + key: join_string(translation.msgid), + value: join_string(translation.msgstr) + } + ] + end + + defp join_string([]), do: nil + defp join_string(list), do: Enum.join(list, "\n") + + defp key_suffix(id), do: ".__KEY__#{id}" +end diff --git a/lib/langue/formatter/gettext/serializer.ex b/lib/langue/formatter/gettext/serializer.ex new file mode 100644 index 00000000..5838c7ef --- /dev/null +++ b/lib/langue/formatter/gettext/serializer.ex @@ -0,0 +1,73 @@ +defmodule Langue.Formatter.Gettext.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.NestedParserHelper + + def serialize(%{entries: entries, top_of_the_file_comment: top_of_the_file_comment, header: header, locale: locale}) do + comments = + top_of_the_file_comment + |> String.trim() + |> split_string() + |> Enum.filter(fn item -> item != "" end) + + headers = + header + |> String.trim() + |> String.replace("\"", "") + |> replace_language_header(locale) + |> split_string() + |> Enum.filter(fn item -> item != "" end) + + render = + %Gettext.PO{ + translations: parse_entries(entries, 0), + top_of_the_file_comments: comments, + headers: headers + } + |> Gettext.PO.dump() + |> IO.iodata_to_binary() + + %Langue.Formatter.SerializerResult{render: render} + end + + defp parse_entries(entries, index) do + entries + |> NestedParserHelper.group_by_key_with_index(index, "__KEY__") + |> Enum.map(&do_parse_entries/1) + end + + defp do_parse_entries({_key, [entry]}) do + %Gettext.PO.Translation{ + comments: split_string(entry.comment), + msgid: split_string(entry.key), + msgstr: split_string(entry.value) + } + end + + defp do_parse_entries({_key, [plural_entry | entries]}) do + msgid = + plural_entry.key + |> remove_key_suffix() + |> split_string() + + %Gettext.PO.PluralTranslation{ + comments: split_string(plural_entry.comment), + msgid: msgid, + msgid_plural: split_string(plural_entry.value), + msgstr: + Enum.reduce(Enum.with_index(entries, 0), %{}, fn {entry, index}, acc -> + value = split_string(entry.value) + + Map.put(acc, index, value) + end) + } + end + + defp split_string(""), do: [] + defp split_string(nil), do: [] + defp split_string(string), do: String.split(string, "\n") + + defp remove_key_suffix(string), do: String.replace(string, ".__KEY___", "") + + defp replace_language_header(string, locale), do: String.replace(string, ~r/Language: [a-zA-Z]+/, "Language: #{locale}") +end diff --git a/lib/langue/formatter/java_properties/parser.ex b/lib/langue/formatter/java_properties/parser.ex new file mode 100644 index 00000000..4052bd20 --- /dev/null +++ b/lib/langue/formatter/java_properties/parser.ex @@ -0,0 +1,15 @@ +defmodule Langue.Formatter.JavaProperties.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Utils.LineByLineHelper + + @prop_line_regex ~r/^(?.+)=(?.*)$/ + + def parse(%{render: render}) do + entries = LineByLineHelper.parse_lines(render, &parse_line/2) + + %Langue.Formatter.ParserResult{entries: entries} + end + + defp parse_line(line, acc), do: LineByLineHelper.parse_line(line, @prop_line_regex, acc) +end diff --git a/lib/langue/formatter/java_properties/serializer.ex b/lib/langue/formatter/java_properties/serializer.ex new file mode 100644 index 00000000..e0e694aa --- /dev/null +++ b/lib/langue/formatter/java_properties/serializer.ex @@ -0,0 +1,13 @@ +defmodule Langue.Formatter.JavaProperties.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.LineByLineHelper + + def serialize(%{entries: entries}) do + render = LineByLineHelper.serialize_lines(entries, "", &prop_line/1) + + %Langue.Formatter.SerializerResult{render: render} + end + + defp prop_line(%Langue.Entry{key: key, value: value}), do: key <> "=" <> value <> "\n" +end diff --git a/lib/langue/formatter/java_properties_xml/parser.ex b/lib/langue/formatter/java_properties_xml/parser.ex new file mode 100644 index 00000000..a0f63d09 --- /dev/null +++ b/lib/langue/formatter/java_properties_xml/parser.ex @@ -0,0 +1,19 @@ +defmodule Langue.Formatter.JavaPropertiesXml.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Utils.LineByLineHelper + + @prop_line_regex ~r/^ +(?.*)<\/entry>$/ + + def parse(%{render: render}) do + entries = LineByLineHelper.parse_lines(render, &parse_line/2) + + %Langue.Formatter.ParserResult{entries: entries} + end + + defp parse_line(" _rest, acc), do: acc + defp parse_line(" _rest, acc), do: acc + defp parse_line("", acc), do: acc + defp parse_line("", acc), do: acc + defp parse_line(line, acc), do: LineByLineHelper.parse_line(line, @prop_line_regex, acc) +end diff --git a/lib/langue/formatter/java_properties_xml/serializer.ex b/lib/langue/formatter/java_properties_xml/serializer.ex new file mode 100644 index 00000000..65af7a4d --- /dev/null +++ b/lib/langue/formatter/java_properties_xml/serializer.ex @@ -0,0 +1,24 @@ +defmodule Langue.Formatter.JavaPropertiesXml.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.LineByLineHelper + + def serialize(%{entries: entries}) do + render = + entries + |> LineByLineHelper.serialize_lines(xml_template(), &prop_line/1) + |> Kernel.<>("\n") + + %Langue.Formatter.SerializerResult{render: render} + end + + defp xml_template do + """ + + + + """ + end + + defp prop_line(%Langue.Entry{key: key, value: value}), do: " " <> value <> "\n" +end diff --git a/lib/langue/formatter/json/parser.ex b/lib/langue/formatter/json/parser.ex new file mode 100644 index 00000000..e92199a2 --- /dev/null +++ b/lib/langue/formatter/json/parser.ex @@ -0,0 +1,18 @@ +defmodule Langue.Formatter.Json.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Utils.NestedParserHelper + + def parse(%{render: render}) do + entries = parse_json(render) + + %Langue.Formatter.ParserResult{entries: entries} + end + + def parse_json(render) do + render + |> :jiffy.decode() + |> elem(0) + |> NestedParserHelper.parse() + end +end diff --git a/lib/langue/formatter/json/serializer.ex b/lib/langue/formatter/json/serializer.ex new file mode 100644 index 00000000..19a817dd --- /dev/null +++ b/lib/langue/formatter/json/serializer.ex @@ -0,0 +1,39 @@ +defmodule Langue.Formatter.Json.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.NestedSerializerHelper + + def serialize(%{entries: entries}) do + render = + entries + |> serialize_json + |> Kernel.<>("\n") + + %Langue.Formatter.SerializerResult{render: render} + end + + def serialize_json(entries) do + %{"" => entries} + |> Enum.with_index(-1) + |> Enum.map(&NestedSerializerHelper.map_value(elem(&1, 0), elem(&1, 1))) + |> List.first() + |> elem(1) + |> Enum.map(&add_extra/1) + |> encode_json() + end + + def encode_json(content) do + {content} + |> :jiffy.encode([:pretty]) + |> String.replace(~r/\" : (\"|{|\[|null|false|true|\d)/, "\": \\1") + end + + defp add_extra({key, [{_, _} | _] = values}), do: {key, {Enum.map(values, &add_extra/1)}} + defp add_extra({key, values}) when is_list(values), do: {key, Enum.map(values, &add_extra/1)} + defp add_extra({key, nil}), do: {key, :null} + defp add_extra({key, values}), do: {key, values} + defp add_extra(values = [{_key, _} | _]), do: {Enum.map(values, &add_extra/1)} + defp add_extra(values) when is_list(values), do: Enum.map(values, &add_extra/1) + defp add_extra(nil), do: :null + defp add_extra(value), do: value +end diff --git a/lib/langue/formatter/parser_result.ex b/lib/langue/formatter/parser_result.ex new file mode 100644 index 00000000..d648c6c1 --- /dev/null +++ b/lib/langue/formatter/parser_result.ex @@ -0,0 +1,8 @@ +defmodule Langue.Formatter.ParserResult do + @type t :: struct + + @enforce_keys [:entries] + defstruct entries: [], top_of_the_file_comment: "", header: "", locale: nil + + def empty, do: %__MODULE__{entries: []} +end diff --git a/lib/langue/formatter/rails/parser.ex b/lib/langue/formatter/rails/parser.ex new file mode 100644 index 00000000..10aecf9b --- /dev/null +++ b/lib/langue/formatter/rails/parser.ex @@ -0,0 +1,17 @@ +defmodule Langue.Formatter.Rails.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Utils.NestedParserHelper + + def parse(%{render: render}) do + {:ok, [content]} = :fast_yaml.decode(render) + + entries = + content + |> Enum.at(0) + |> elem(1) + |> NestedParserHelper.parse() + + %Langue.Formatter.ParserResult{entries: entries} + end +end diff --git a/lib/langue/formatter/rails/serializer.ex b/lib/langue/formatter/rails/serializer.ex new file mode 100644 index 00000000..3d425f40 --- /dev/null +++ b/lib/langue/formatter/rails/serializer.ex @@ -0,0 +1,20 @@ +defmodule Langue.Formatter.Rails.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.NestedSerializerHelper + + @white_space_regex ~r/(:|-) \n/ + + def serialize(%{entries: entries, locale: locale}) do + render = + %{locale => entries} + |> Enum.with_index(-1) + |> Enum.map(&NestedSerializerHelper.map_value(elem(&1, 0), elem(&1, 1))) + |> :fast_yaml.encode() + |> IO.iodata_to_binary() + |> String.replace(@white_space_regex, "\\1\n") + |> Kernel.<>("\n") + + %Langue.Formatter.SerializerResult{render: render} + end +end diff --git a/lib/langue/formatter/serializer_result.ex b/lib/langue/formatter/serializer_result.ex new file mode 100644 index 00000000..cec16159 --- /dev/null +++ b/lib/langue/formatter/serializer_result.ex @@ -0,0 +1,8 @@ +defmodule Langue.Formatter.SerializerResult do + @type t :: struct + + @enforce_keys [:render] + defstruct render: "" + + def empty, do: %__MODULE__{render: ""} +end diff --git a/lib/langue/formatter/simple_json/parser.ex b/lib/langue/formatter/simple_json/parser.ex new file mode 100644 index 00000000..3b43eadb --- /dev/null +++ b/lib/langue/formatter/simple_json/parser.ex @@ -0,0 +1,7 @@ +defmodule Langue.Formatter.SimpleJson.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Formatter.Json.Parser, as: JsonParser + + def parse(data), do: JsonParser.parse(data) +end diff --git a/lib/langue/formatter/simple_json/serializer.ex b/lib/langue/formatter/simple_json/serializer.ex new file mode 100644 index 00000000..1e458623 --- /dev/null +++ b/lib/langue/formatter/simple_json/serializer.ex @@ -0,0 +1,18 @@ +defmodule Langue.Formatter.SimpleJson.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.NestedSerializerHelper + alias Langue.Formatter.Json.Serializer, as: JsonSerializer + + def serialize(%{entries: entries}) do + render = + entries + |> Enum.map(fn entry -> + {entry.key, NestedSerializerHelper.entry_value_to_string(entry.value, entry.value_type)} + end) + |> JsonSerializer.encode_json() + |> Kernel.<>("\n") + + %Langue.Formatter.SerializerResult{render: render} + end +end diff --git a/lib/langue/formatter/strings/parser.ex b/lib/langue/formatter/strings/parser.ex new file mode 100644 index 00000000..5f448595 --- /dev/null +++ b/lib/langue/formatter/strings/parser.ex @@ -0,0 +1,15 @@ +defmodule Langue.Formatter.Strings.Parser do + @behaviour Langue.Formatter.Parser + + alias Langue.Utils.LineByLineHelper + + @prop_line_regex ~r/^(?.+)?"(?.+)" ?= ?"(?.*)"$/sm + + def parse(%{render: render}) do + entries = LineByLineHelper.parse_lines(render, &parse_line/2, ";\n") + + %Langue.Formatter.ParserResult{entries: entries} + end + + defp parse_line(line, acc), do: LineByLineHelper.parse_line(line, @prop_line_regex, acc) +end diff --git a/lib/langue/formatter/strings/serializer.ex b/lib/langue/formatter/strings/serializer.ex new file mode 100644 index 00000000..fa453d38 --- /dev/null +++ b/lib/langue/formatter/strings/serializer.ex @@ -0,0 +1,14 @@ +defmodule Langue.Formatter.Strings.Serializer do + @behaviour Langue.Formatter.Serializer + + alias Langue.Utils.LineByLineHelper + + def serialize(%{entries: entries}) do + render = LineByLineHelper.serialize_lines(entries, "", &prop_line/1) + + %Langue.Formatter.SerializerResult{render: render} + end + + defp prop_line(%Langue.Entry{key: key, value: nil}), do: "\"" <> key <> "\"" <> " = " <> "\"\";\n" + defp prop_line(%Langue.Entry{key: key, value: value}), do: "\"" <> key <> "\"" <> " = " <> "\"" <> value <> "\";\n" +end diff --git a/lib/langue/utils/line_by_line_helper.ex b/lib/langue/utils/line_by_line_helper.ex new file mode 100644 index 00000000..07292e5b --- /dev/null +++ b/lib/langue/utils/line_by_line_helper.ex @@ -0,0 +1,40 @@ +defmodule Langue.Utils.LineByLineHelper do + alias Langue.Entry + + def parse_lines(render, parse_line, split \\ "\n") do + render + |> String.split(split) + |> Enum.reduce(%{comment: [], entries: [], index: 1}, &parse_line.(&1, &2)) + |> Map.get(:entries) + end + + def serialize_lines(entries, text_acc, prop_line) do + Enum.reduce(entries, text_acc, &Kernel.<>(&2, serialize_line(&1, prop_line))) + end + + defp serialize_line(entry = %Entry{comment: comment}, prop_line) when not is_nil(comment) and comment !== "" do + comment <> "\n" <> prop_line.(entry) + end + + defp serialize_line(entry = %Entry{}, prop_line) do + prop_line.(entry) + end + + def parse_line(line, prop_line_regex, acc) do + case Regex.named_captures(prop_line_regex, line) do + %{"key" => key, "value" => value, "comment" => comment} -> + acc + |> Map.put(:entries, Enum.concat(acc.entries, [%Entry{key: key, value: value, index: acc.index, comment: String.trim_trailing(comment, "\n")}])) + |> Map.put(:index, acc.index + 1) + + %{"key" => key, "value" => value} -> + acc + |> Map.put(:entries, Enum.concat(acc.entries, [%Entry{key: key, value: value, index: acc.index, comment: Enum.join(acc.comment, "\n")}])) + |> Map.put(:comment, []) + |> Map.put(:index, acc.index + 1) + + nil -> + Map.put(acc, :comment, Enum.concat(acc.comment, [line])) + end + end +end diff --git a/lib/langue/utils/nested_parser_helper.ex b/lib/langue/utils/nested_parser_helper.ex new file mode 100644 index 00000000..a392f276 --- /dev/null +++ b/lib/langue/utils/nested_parser_helper.ex @@ -0,0 +1,81 @@ +defmodule Langue.Utils.NestedParserHelper do + alias Langue.Entry + + @nested_separator "." + + def group_by_key_with_index(entries, index, nested_separator \\ @nested_separator) do + grouped_entries = + entries + |> Enum.group_by(fn entry -> + entry.key |> String.split(nested_separator) |> Enum.at(index) + end) + + entries + |> Enum.reduce(%{keys: MapSet.new(), results: []}, fn entry, acc -> + key = entry.key |> String.split(nested_separator) |> Enum.at(index) + + if MapSet.member?(acc.keys, key) do + acc + else + key_entries = grouped_entries[key] + + acc + |> Map.put(:results, List.insert_at(acc.results, -1, {key, key_entries})) + |> Map.put(:keys, MapSet.put(acc.keys, key)) + end + end) + |> Map.get(:results) + end + + def parse(data) do + data + |> Enum.map(&flattenize_tuple(&1)) + |> List.flatten() + |> Enum.with_index(1) + |> Enum.map(fn {entry, index} -> %{entry | index: index} end) + end + + defp flattenize_array({key, value, index}), do: flattenize_tuple({"#{key}#{@nested_separator}__KEY__#{index}", value, ""}) + + defp flattenize_tuple({key, value}), do: flattenize_tuple({key, value, ""}) + defp flattenize_tuple({key, value, type}) when is_tuple(value), do: flattenize_tuple({key, elem(value, 0), type}) + + defp flattenize_tuple({key, value, _type}) when is_boolean(value) or value == "false" or value == "true" do + %Entry{key: key, value: entry_value_to_string(value), value_type: "boolean", comment: ""} + end + + defp flattenize_tuple({key, value, _type}) when value == "" do + %Entry{key: key, value: entry_value_to_string(value), value_type: "empty", comment: ""} + end + + defp flattenize_tuple({key, value, _type}) when value == :null or value == "nil" do + %Entry{key: key, value: entry_value_to_string(value), value_type: "null", comment: ""} + end + + defp flattenize_tuple({key, value, _type}) when is_integer(value) do + %Entry{key: key, value: entry_value_to_string(to_string(value)), value_type: "integer", comment: ""} + end + + defp flattenize_tuple({key, value, ""}), do: flattenize_tuple({key, value, nil}) + + defp flattenize_tuple({key, value, type}) when is_binary(value) do + %Entry{key: key, value: entry_value_to_string(value), value_type: type, comment: ""} + end + + defp flattenize_tuple({key, value, type}) when is_list(value) do + value + |> Enum.with_index() + |> Enum.map(fn {item, index} -> + if is_tuple(item) && !is_list(elem(item, 0)) do + flattenize_tuple({"#{key}#{@nested_separator}#{elem(item, 0)}", elem(item, 1), type}) + else + flattenize_array({key, item, index}) + end + end) + end + + defp entry_value_to_string(true), do: "true" + defp entry_value_to_string(false), do: "false" + defp entry_value_to_string(:null), do: "null" + defp entry_value_to_string(value), do: value +end diff --git a/lib/langue/utils/nested_serializer_helper.ex b/lib/langue/utils/nested_serializer_helper.ex new file mode 100644 index 00000000..5c3b5aff --- /dev/null +++ b/lib/langue/utils/nested_serializer_helper.ex @@ -0,0 +1,43 @@ +defmodule Langue.Utils.NestedSerializerHelper do + alias Langue.Utils.NestedParserHelper + + def map_value({nil, [%{value: value, value_type: type} | _t]}, _index), do: entry_value_to_string(value, type) + def map_value({key, values}, index) when is_binary(key), do: {key, group_by(values, index + 1)} + + defp group_by(entries, index) do + entries + |> NestedParserHelper.group_by_key_with_index(index) + |> parse_children(index) + end + + defp parse_children(entries = [{"__KEY__" <> _array_index, _values} | _rest], index) do + entries + |> Enum.map(fn {_key, values} -> group_by(values, index + 1) end) + end + + defp parse_children(entries, index) do + entries + |> Enum.map(&map_value(&1, index)) + |> extract_single_value + end + + defp extract_single_value([null_value | _rest]) when is_nil(null_value), do: null_value + defp extract_single_value([boolean | _rest]) when is_boolean(boolean), do: boolean + defp extract_single_value([string | _rest]) when is_binary(string), do: string + defp extract_single_value([integer | _rest]) when is_integer(integer), do: integer + defp extract_single_value(values), do: values + + def entry_value_to_string("true", "boolean"), do: true + def entry_value_to_string("false", "boolean"), do: false + def entry_value_to_string("null", "null"), do: nil + + def entry_value_to_string(integer, "integer") do + case Integer.parse(integer) do + {parsed, _rest} -> parsed + :error -> integer + end + end + + def entry_value_to_string(nil, _type), do: "" + def entry_value_to_string(string, _type), do: string +end diff --git a/lib/langue/utils/parser.ex b/lib/langue/utils/parser.ex new file mode 100644 index 00000000..f184d70f --- /dev/null +++ b/lib/langue/utils/parser.ex @@ -0,0 +1,3 @@ +defmodule Langue.Formatter.Parser do + @callback parse(Langue.Formatter.SerializerResult.t()) :: Langue.Formatter.ParserResult.t() +end diff --git a/lib/langue/utils/serializer.ex b/lib/langue/utils/serializer.ex new file mode 100644 index 00000000..1eebcefe --- /dev/null +++ b/lib/langue/utils/serializer.ex @@ -0,0 +1,3 @@ +defmodule Langue.Formatter.Serializer do + @callback serialize(Langue.Formatter.ParserResult.t()) :: Langue.Formatter.SerializerResult.t() +end diff --git a/lib/movement/builder.ex b/lib/movement/builder.ex new file mode 100644 index 00000000..e784c60a --- /dev/null +++ b/lib/movement/builder.ex @@ -0,0 +1,3 @@ +defmodule Movement.Builder do + @callback build(Movement.Context.t()) :: Movement.Context.t() +end diff --git a/lib/movement/builders/document_delete.ex b/lib/movement/builders/document_delete.ex new file mode 100644 index 00000000..67ae5de5 --- /dev/null +++ b/lib/movement/builders/document_delete.ex @@ -0,0 +1,43 @@ +defmodule Movement.Builders.DocumentDelete do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.{Repo, Translation, Project} + + @action "remove" + + def build(context) do + context + |> assign_translations + |> assign_project + |> process_operations + end + + defp assign_translations(context) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.from_document(context.assigns[:document].id) + |> Repo.all() + + assign(context, :translations, translations) + end + + defp assign_project(context) do + project = Repo.get(Project, context.assigns[:document].project_id) + + assign(context, :project, project) + end + + defp process_operations(context = %Movement.Context{assigns: %{translations: translations}, operations: operations}) do + new_operations = + Enum.map(translations, fn translation -> + OperationMapper.map(@action, translation, %{translation | marked_as_removed: true}) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end +end diff --git a/lib/movement/builders/new_slave.ex b/lib/movement/builders/new_slave.ex new file mode 100644 index 00000000..fc8b2316 --- /dev/null +++ b/lib/movement/builders/new_slave.ex @@ -0,0 +1,55 @@ +defmodule Movement.Builders.NewSlave do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.Scopes.Revision, as: RevisionScope + alias Accent.{Translation, Revision, Repo} + + @action "new" + + def build(context) do + context + |> assign_master_revision + |> assign_translations + |> process_operations + end + + defp process_operations(context = %Movement.Context{assigns: assigns, operations: operations}) do + new_operations = + Enum.map(assigns[:translations], fn translation -> + OperationMapper.map(@action, translation, %{ + key: translation.key, + text: translation.corrected_text, + file_comment: translation.file_comment, + file_index: translation.file_index, + document_id: translation.document_id, + version_id: translation.version_id, + value_type: translation.value_type + }) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + defp assign_translations(context = %Movement.Context{assigns: assigns}) do + translations = + Translation + |> TranslationScope.from_revision(assigns[:master_revision].id) + |> Repo.all() + + assign(context, :translations, translations) + end + + defp assign_master_revision(context = %Movement.Context{assigns: assigns}) do + master_revision = + Revision + |> RevisionScope.from_project(assigns[:project].id) + |> RevisionScope.master() + |> Repo.one!() + + assign(context, :master_revision, master_revision) + end +end diff --git a/lib/movement/builders/new_version.ex b/lib/movement/builders/new_version.ex new file mode 100644 index 00000000..afc4d689 --- /dev/null +++ b/lib/movement/builders/new_version.ex @@ -0,0 +1,38 @@ +defmodule Movement.Builders.NewVersion do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.{Translation, Repo} + + @action "version_new" + + def build(context) do + context + |> assign_translations + |> process_operations + end + + defp process_operations(context = %Movement.Context{assigns: assigns, operations: operations}) do + new_operations = + Enum.map(assigns[:translations], fn translation -> + OperationMapper.map(@action, translation, %{ + text: translation.corrected_text + }) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + defp assign_translations(context = %Movement.Context{assigns: assigns}) do + translations = + Translation + |> TranslationScope.from_project(assigns[:project].id) + |> TranslationScope.no_version() + |> Repo.all() + + assign(context, :translations, translations) + end +end diff --git a/lib/movement/builders/project_sync.ex b/lib/movement/builders/project_sync.ex new file mode 100644 index 00000000..2b022963 --- /dev/null +++ b/lib/movement/builders/project_sync.ex @@ -0,0 +1,59 @@ +defmodule Movement.Builders.ProjectSync do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Builders.RevisionSync, as: RevisionSyncBuilder + alias Movement.Builders.SlaveConflictSync, as: SlaveConflictSyncBuilder + + alias Accent.Scopes.Revision, as: RevisionScope + + alias Accent.{Repo, Revision} + + def build(context) do + # Don’t keep track of the last used revision to prevent error on further steps + context + |> Map.put(:operations, []) + |> generate_operations + |> assign(:revision, nil) + end + + defp generate_operations(context = %Movement.Context{assigns: assigns}) do + master_revision = + Revision + |> RevisionScope.from_project(assigns[:project].id) + |> RevisionScope.master() + |> Repo.one!() + |> Repo.preload(:language) + + slave_revisions = + Revision + |> RevisionScope.from_project(assigns[:project].id) + |> RevisionScope.slaves() + |> Repo.all() + |> Repo.preload(:language) + + context + |> assign(:master_revision, master_revision) + |> assign(:slave_revisions, slave_revisions) + |> assign_revisions_operations + end + + defp assign_revisions_operations(context) do + # Master revision + # Slave revisions conflicts + context = + context + |> assign(:revision, context.assigns[:master_revision]) + |> RevisionSyncBuilder.build() + |> assign(:revisions, context.assigns[:slave_revisions]) + |> SlaveConflictSyncBuilder.build() + + # Slave revisions add/remove + Enum.reduce(context.assigns[:slave_revisions], context, fn revision, acc -> + acc + |> assign(:revision, revision) + |> RevisionSyncBuilder.build() + end) + end +end diff --git a/lib/movement/builders/revision_correct_all.ex b/lib/movement/builders/revision_correct_all.ex new file mode 100644 index 00000000..1bb8b005 --- /dev/null +++ b/lib/movement/builders/revision_correct_all.ex @@ -0,0 +1,37 @@ +defmodule Movement.Builders.RevisionCorrectAll do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.{Repo, Translation} + + @action "correct_conflict" + + def build(context) do + context + |> assign_translations + |> process_operations + end + + defp process_operations(context = %Movement.Context{assigns: assigns, operations: operations}) do + new_operations = + Enum.map(assigns[:translations], fn translation -> + OperationMapper.map(@action, translation, %{text: translation.corrected_text}) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + defp assign_translations(context = %Movement.Context{assigns: assigns}) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.conflicted() + |> TranslationScope.from_revision(assigns[:revision].id) + |> Repo.all() + + assign(context, :translations, translations) + end +end diff --git a/lib/movement/builders/revision_merge.ex b/lib/movement/builders/revision_merge.ex new file mode 100644 index 00000000..10560404 --- /dev/null +++ b/lib/movement/builders/revision_merge.ex @@ -0,0 +1,27 @@ +defmodule Movement.Builders.RevisionMerge do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.EntriesCommitProcessor + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.Repo + alias Accent.Translation + + def build(context) do + context + |> assign_translations() + |> EntriesCommitProcessor.process() + end + + defp assign_translations(context) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.from_revision(context.assigns[:revision].id) + |> TranslationScope.from_document(context.assigns[:document].id) + |> Repo.all() + + assign(context, :translations, translations) + end +end diff --git a/lib/movement/builders/revision_sync.ex b/lib/movement/builders/revision_sync.ex new file mode 100644 index 00000000..1830a222 --- /dev/null +++ b/lib/movement/builders/revision_sync.ex @@ -0,0 +1,27 @@ +defmodule Movement.Builders.RevisionSync do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.EntriesCommitProcessor + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.Repo + alias Accent.Translation + + def build(context) do + context + |> assign_translations + |> EntriesCommitProcessor.process() + |> EntriesCommitProcessor.process_for_remove() + end + + defp assign_translations(context) do + translations = + Translation + |> TranslationScope.from_revision(context.assigns[:revision].id) + |> TranslationScope.from_document(context.assigns[:document].id) + |> Repo.all() + + assign(context, :translations, translations) + end +end diff --git a/lib/movement/builders/revision_uncorrect_all.ex b/lib/movement/builders/revision_uncorrect_all.ex new file mode 100644 index 00000000..38f6ee1c --- /dev/null +++ b/lib/movement/builders/revision_uncorrect_all.ex @@ -0,0 +1,37 @@ +defmodule Movement.Builders.RevisionUncorrectAll do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.{Repo, Translation} + + @action "uncorrect_conflict" + + def build(context) do + context + |> assign_translations + |> process_operations + end + + defp process_operations(context = %Movement.Context{assigns: assigns, operations: operations}) do + new_operations = + Enum.map(assigns[:translations], fn translation -> + OperationMapper.map(@action, translation, %{text: nil}) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + defp assign_translations(context = %Movement.Context{assigns: assigns}) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.not_conflicted() + |> TranslationScope.from_revision(assigns[:revision].id) + |> Repo.all() + + assign(context, :translations, translations) + end +end diff --git a/lib/movement/builders/rollback.ex b/lib/movement/builders/rollback.ex new file mode 100644 index 00000000..922055f8 --- /dev/null +++ b/lib/movement/builders/rollback.ex @@ -0,0 +1,40 @@ +defmodule Movement.Builders.Rollback do + @behaviour Movement.Builder + + alias Movement.Operation + alias Accent.PreviousTranslation + + @action "rollback" + + # Batch operation + def build(context = %Movement.Context{assigns: %{operation: operation = %{batch: true}}, operations: operations}) do + new_operation = %Operation{ + action: @action, + key: operation.key, + batch: true, + translation_id: operation.translation_id, + revision_id: operation.revision_id, + project_id: operation.project_id, + document_id: operation.document_id, + rollbacked_operation_id: operation.id + } + + %{context | operations: Enum.concat(operations, [new_operation])} + end + + # Translation operation + def build(context = %Movement.Context{assigns: %{operation: operation}, operations: operations}) do + new_operation = %Operation{ + action: @action, + key: operation.translation.key, + previous_translation: PreviousTranslation.from_translation(operation.translation), + translation_id: operation.translation_id, + revision_id: operation.revision_id, + project_id: operation.project_id, + document_id: operation.document_id, + rollbacked_operation_id: operation.id + } + + %{context | operations: Enum.concat(operations, [new_operation])} + end +end diff --git a/lib/movement/builders/slave_conflict_sync.ex b/lib/movement/builders/slave_conflict_sync.ex new file mode 100644 index 00000000..a8a3c758 --- /dev/null +++ b/lib/movement/builders/slave_conflict_sync.ex @@ -0,0 +1,59 @@ +defmodule Movement.Builders.SlaveConflictSync do + @behaviour Movement.Builder + + import Movement.Context, only: [assign: 3] + + alias Movement.Mappers.Operation, as: OperationMapper + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.{Repo, Translation} + + @included_actions ~w(conflict_on_corrected conflict_on_proposed) + @action "conflict_on_slave" + + def build(context = %Movement.Context{}) do + context + |> assign_revision_ids + |> assign_operation_keys + |> assign_translations + |> process_operations + end + + defp process_operations(context = %Movement.Context{assigns: assigns}) do + new_operations = + Enum.map(assigns[:translations], fn translation -> + OperationMapper.map(@action, translation, %{ + text: translation.corrected_text, + key: translation.key, + file_comment: translation.file_comment, + file_index: translation.file_index + }) + end) + + %{context | operations: Enum.concat(context.operations, new_operations)} + end + + defp assign_translations(context = %Movement.Context{assigns: assigns}) do + translations = + Translation + |> TranslationScope.active() + |> TranslationScope.from_revisions(assigns[:revision_ids]) + |> TranslationScope.from_keys(assigns[:translation_keys]) + |> Repo.all() + |> Repo.preload(:revision) + + assign(context, :translations, translations) + end + + defp assign_revision_ids(context = %Movement.Context{assigns: assigns}) do + assign(context, :revision_ids, Enum.map(assigns[:revisions], &Map.get(&1, :id))) + end + + defp assign_operation_keys(context = %Movement.Context{operations: operations}) do + keys = + operations + |> Enum.filter(fn %{action: action} -> action in @included_actions end) + |> Enum.map(&Map.get(&1, :key)) + + assign(context, :translation_keys, keys) + end +end diff --git a/lib/movement/builders/translation_correct_conflict.ex b/lib/movement/builders/translation_correct_conflict.ex new file mode 100644 index 00000000..c05818c7 --- /dev/null +++ b/lib/movement/builders/translation_correct_conflict.ex @@ -0,0 +1,13 @@ +defmodule Movement.Builders.TranslationCorrectConflict do + @behaviour Movement.Builder + + alias Movement.Mappers.Operation, as: OperationMapper + + @action "correct_conflict" + + def build(context = %Movement.Context{assigns: %{translation: translation, text: text}, operations: operations}) do + operation = OperationMapper.map(@action, translation, %{text: text}) + + %{context | operations: Enum.concat(operations, [operation])} + end +end diff --git a/lib/movement/builders/translation_uncorrect_conflict.ex b/lib/movement/builders/translation_uncorrect_conflict.ex new file mode 100644 index 00000000..57953052 --- /dev/null +++ b/lib/movement/builders/translation_uncorrect_conflict.ex @@ -0,0 +1,13 @@ +defmodule Movement.Builders.TranslationUncorrectConflict do + @behaviour Movement.Builder + + alias Movement.Mappers.Operation, as: OperationMapper + + @action "uncorrect_conflict" + + def build(context = %Movement.Context{assigns: %{translation: translation}, operations: operations}) do + operation = OperationMapper.map(@action, translation, %{text: nil}) + + %{context | operations: Enum.concat(operations, [operation])} + end +end diff --git a/lib/movement/builders/translation_update.ex b/lib/movement/builders/translation_update.ex new file mode 100644 index 00000000..2addc42e --- /dev/null +++ b/lib/movement/builders/translation_update.ex @@ -0,0 +1,27 @@ +defmodule Movement.Builders.TranslationUpdate do + @behaviour Movement.Builder + + alias Movement.Mappers.Operation, as: OperationMapper + + @action "update" + + def build(context = %Movement.Context{assigns: %{text: text, translation: %{corrected_text: corrected_text}}}) when text === corrected_text, do: context + + def build(context = %Movement.Context{assigns: %{translation: translation, text: text}, operations: operations}) do + value_type = parse_value_type(translation, text) + operation = OperationMapper.map(@action, translation, build_updated_translation(text, value_type)) + + %{context | operations: Enum.concat(operations, [operation])} + end + + defp parse_value_type(_translation, ""), do: "empty" + defp parse_value_type(%{value_type: "null"}, value) when value != "null", do: "" + defp parse_value_type(%{value_type: "empty"}, value) when value != "", do: "" + defp parse_value_type(_translation, _value), do: nil + + defp build_updated_translation(text, nil), do: %{text: text} + + defp build_updated_translation(text, value_type) do + %{text: text, value_type: value_type} + end +end diff --git a/lib/movement/comparer.ex b/lib/movement/comparer.ex new file mode 100644 index 00000000..e3096301 --- /dev/null +++ b/lib/movement/comparer.ex @@ -0,0 +1,3 @@ +defmodule Movement.Comparer do + @callback compare(map, map) :: Movement.Operation.t() +end diff --git a/lib/movement/comparers/merge_force.ex b/lib/movement/comparers/merge_force.ex new file mode 100644 index 00000000..4134e3c7 --- /dev/null +++ b/lib/movement/comparers/merge_force.ex @@ -0,0 +1,54 @@ +defmodule Movement.Comparers.MergeForce do + @behaviour Movement.Comparer + + alias Movement.Mappers.Operation, as: OperationMapper + + alias Movement.{ + Operation, + TranslationComparer + } + + @doc """ + ## Examples + + iex> translation = %Accent.Translation{key: "a", proposed_text: "foo", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeForce.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_corrected_force" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "foo"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeForce.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "baz"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeForce.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_corrected_force" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeForce.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_proposed_force" + + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeForce.compare(nil, suggested_translation) |> Map.get(:action) + "noop" + """ + def compare(nil, suggested_translation), do: %Operation{action: "noop", key: suggested_translation.key} + + def compare(translation, suggested_translation) do + suggested_translation = %{suggested_translation | revision_id: translation.revision_id} + + case TranslationComparer.compare(translation, suggested_translation.text) do + {action, _text} when action in ~w(conflict_on_corrected autocorrect) -> + OperationMapper.map("merge_on_corrected_force", translation, suggested_translation) + + {"conflict_on_proposed", _text} -> + OperationMapper.map("merge_on_proposed_force", translation, suggested_translation) + + {_action, _text} -> + %Operation{action: "noop", key: translation.key} + end + end +end diff --git a/lib/movement/comparers/merge_passive.ex b/lib/movement/comparers/merge_passive.ex new file mode 100644 index 00000000..b5625f56 --- /dev/null +++ b/lib/movement/comparers/merge_passive.ex @@ -0,0 +1,58 @@ +defmodule Movement.Comparers.MergePassive do + @behaviour Movement.Comparer + + alias Movement.Mappers.Operation, as: OperationMapper + + alias Movement.{ + Operation, + TranslationComparer + } + + @doc """ + ## Examples + + iex> translation = %Accent.Translation{key: "a", conflicted: true, proposed_text: "foo", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + + iex> translation = %Accent.Translation{key: "a", conflicted: true, proposed_text: "bar", corrected_text: "foo"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(nil, suggested_translation) |> Map.get(:action) + "noop" + + iex> translation = %Accent.Translation{key: "a", conflicted: true, proposed_text: "bar", corrected_text: "baz"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + + iex> translation = %Accent.Translation{key: "a", conflicted: true, proposed_text: "bar", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_proposed" + + iex> translation = %Accent.Translation{key: "a", conflicted: false, proposed_text: "bar", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergePassive.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + """ + def compare(nil, suggested_translation), do: %Operation{action: "noop", key: suggested_translation.key} + def compare(%{conflicted: false}, suggested_translation), do: %Operation{action: "noop", key: suggested_translation.key} + + def compare(translation, suggested_translation) do + case TranslationComparer.compare(translation, suggested_translation.text) do + {"conflict_on_proposed", new_text} -> + suggested_translation = %{suggested_translation | text: new_text} + suggested_translation = %{suggested_translation | revision_id: translation.revision_id} + + OperationMapper.map("merge_on_proposed", translation, suggested_translation) + + {_action, _text} -> + %Operation{action: "noop", key: translation.key} + end + end +end diff --git a/lib/movement/comparers/merge_smart.ex b/lib/movement/comparers/merge_smart.ex new file mode 100644 index 00000000..abe5ed68 --- /dev/null +++ b/lib/movement/comparers/merge_smart.ex @@ -0,0 +1,63 @@ +defmodule Movement.Comparers.MergeSmart do + @behaviour Movement.Comparer + + alias Movement.Mappers.Operation, as: OperationMapper + + alias Movement.{ + Operation, + TranslationComparer + } + + @doc """ + ## Examples + + iex> translation = %Accent.Translation{key: "a", proposed_text: "foo", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeSmart.compare(translation, suggested_translation) |> Map.get(:action) + "noop" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "foo"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeSmart.compare(translation, suggested_translation) |> Map.get(:action) + "update_proposed" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "baz"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeSmart.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_corrected" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeSmart.compare(translation, suggested_translation) |> Map.get(:action) + "merge_on_proposed" + + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.MergeSmart.compare(nil, suggested_translation) |> Map.get(:action) + "noop" + """ + def compare(nil, suggested_translation), do: %Operation{action: "noop", key: suggested_translation.key} + + def compare(translation, suggested_translation) do + suggested_translation = %{suggested_translation | revision_id: translation.revision_id} + + case TranslationComparer.compare(translation, suggested_translation.text) do + {"update_proposed", new_text} -> + suggested_translation = %{suggested_translation | text: new_text} + + OperationMapper.map("update_proposed", translation, suggested_translation) + + {"conflict_on_proposed", new_text} -> + suggested_translation = %{suggested_translation | text: new_text} + + OperationMapper.map("merge_on_proposed", translation, suggested_translation) + + {"conflict_on_corrected", new_text} -> + suggested_translation = %{suggested_translation | text: new_text} + + OperationMapper.map("merge_on_corrected", translation, suggested_translation) + + {_action, _text} -> + %Operation{action: "noop", key: translation.key} + end + end +end diff --git a/lib/movement/comparers/sync.ex b/lib/movement/comparers/sync.ex new file mode 100644 index 00000000..1ee9b750 --- /dev/null +++ b/lib/movement/comparers/sync.ex @@ -0,0 +1,45 @@ +defmodule Movement.Comparers.Sync do + @behaviour Movement.Comparer + + alias Movement.Mappers.Operation, as: OperationMapper + + alias Movement.{ + Operation, + TranslationComparer + } + + @doc """ + ## Examples + + iex> translation = %Accent.Translation{key: "a", proposed_text: "foo", corrected_text: "bar"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.Sync.compare(translation, suggested_translation) |> Map.get(:action) + "autocorrect" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "foo"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.Sync.compare(translation, suggested_translation) |> Map.get(:action) + "update_proposed" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "baz"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.Sync.compare(translation, suggested_translation) |> Map.get(:action) + "conflict_on_corrected" + + iex> translation = %Accent.Translation{key: "a", proposed_text: "bar", corrected_text: "baz"} + iex> suggested_translation = %Movement.SuggestedTranslation{key: "a", text: "foo"} + iex> Movement.Comparers.Sync.compare(translation, suggested_translation) |> Map.get(:text) + "foo" + """ + def compare(translation, suggested_translation) do + case TranslationComparer.compare(translation, suggested_translation.text) do + {action, _text} when action in ~w(autocorrect) -> + %Operation{action: action, key: translation.key} + + {action, text} -> + suggested_translation = %{suggested_translation | text: text} + + OperationMapper.map(action, translation, suggested_translation) + end + end +end diff --git a/lib/movement/context.ex b/lib/movement/context.ex new file mode 100644 index 00000000..3629980f --- /dev/null +++ b/lib/movement/context.ex @@ -0,0 +1,11 @@ +defmodule Movement.Context do + defstruct entries: [], operations: [], assigns: %{}, render: "" + + @type t :: %__MODULE__{} + + def assign(context, key, value) do + new_assign = Map.put(%{}, key, value) + + Map.put(context, :assigns, Map.merge(context.assigns, new_assign)) + end +end diff --git a/lib/movement/ecto_migration_helper.ex b/lib/movement/ecto_migration_helper.ex new file mode 100644 index 00000000..347f4386 --- /dev/null +++ b/lib/movement/ecto_migration_helper.ex @@ -0,0 +1,21 @@ +defmodule Movement.EctoMigrationHelper do + alias Movement.Migration + alias Accent.Repo + + @doc """ + Update given model by merging the existing parameters and the arguments. + """ + @spec update(model :: map, params :: map()) :: Migration.t() + def update(model, params) do + params = Map.put(params, :updated_at, NaiveDateTime.utc_now()) + + model + |> model.__struct__.changeset(params) + |> Repo.update() + end + + def insert(model) do + model + |> Repo.insert() + end +end diff --git a/lib/movement/entries_commit_processor.ex b/lib/movement/entries_commit_processor.ex new file mode 100644 index 00000000..0f87aa9b --- /dev/null +++ b/lib/movement/entries_commit_processor.ex @@ -0,0 +1,79 @@ +defmodule Movement.EntriesCommitProcessor do + @no_action_keys ~w(noop autocorrect) + @included_slave_actions ~w(new remove renew merge_on_corrected merge_on_proposed merge_on_proposed_force merge_on_corrected_force) + + @doc """ + For list of translations, new data (like the content of a file upload) and a given function, + returns the list of operations that will be executed. The operations will be neither persisted nor run. + + The list contains the operations for keys that exists in the current translations list. For the removal of + keys, use the process_for_remove/3 function. + """ + @spec process(Movement.Context.t()) :: Movement.Context.t() + def process(context = %Movement.Context{entries: entries, assigns: assigns, operations: operations}) do + grouped_translations = group_by_key(assigns[:translations]) + + new_operations = + entries + |> Enum.map(fn entry -> + current_translation = fetch_current_translation(grouped_translations, entry.key) + + suggested_translation = %Movement.SuggestedTranslation{ + text: entry.value, + key: entry.key, + file_comment: entry.comment, + file_index: entry.index, + value_type: entry.value_type, + revision_id: Map.get(assigns[:revision], :id) + } + + assigns[:comparer].(current_translation, suggested_translation) + end) + |> filter_for_revision(assigns[:revision]) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + @doc """ + For list of translations and new data (like the content of a file upload), + returns the list of operations concerning removed keys from the content that will be exectued. + """ + @spec process_for_remove(Movement.Context.t()) :: Movement.Context.t() + def process_for_remove(context = %Movement.Context{entries: entries, assigns: assigns, operations: operations}) do + grouped_entries = group_by_key(entries) + grouped_entries_keys = Map.keys(grouped_entries) + + new_operations = + assigns[:translations] + |> Enum.filter(fn translation -> !translation.removed && translation.key not in grouped_entries_keys end) + |> Enum.map(fn current_translation -> + suggested_translation = %{current_translation | marked_as_removed: true} + + assigns[:comparer].(suggested_translation, suggested_translation) + end) + + %{context | operations: Enum.concat(operations, new_operations)} + end + + defp group_by_key(list), do: Enum.group_by(list, &Map.get(&1, :key)) + + defp fetch_current_translation(grouped_translations, key) do + grouped_translations + |> Map.get(key) + |> case do + [value | _rest] when is_map(value) -> value + _ -> nil + end + end + + defp filter_for_revision(operations, %{master: true}) do + operations + |> Enum.filter(fn %{action: operation} -> operation not in @no_action_keys end) + end + + defp filter_for_revision(operations, _) do + operations + |> Enum.filter(fn %{action: operation} -> operation in @included_slave_actions end) + |> Enum.filter(fn %{action: operation} -> operation not in @no_action_keys end) + end +end diff --git a/lib/movement/mappers/operation.ex b/lib/movement/mappers/operation.ex new file mode 100644 index 00000000..975d9699 --- /dev/null +++ b/lib/movement/mappers/operation.ex @@ -0,0 +1,47 @@ +defmodule Movement.Mappers.Operation do + @spec map(binary, map, map) :: Movement.Operation.t() + def map(action = "new", current_translation, suggested_translation) do + %Movement.Operation{ + action: action, + text: suggested_translation.text, + key: suggested_translation.key, + file_comment: suggested_translation.file_comment, + file_index: suggested_translation.file_index, + value_type: Map.get(suggested_translation, :value_type), + revision_id: Map.get(suggested_translation, :revision_id), + document_id: Map.get(suggested_translation, :document_id), + version_id: Map.get(suggested_translation, :version_id), + previous_translation: from_translation(current_translation) + } + end + + def map(action, current_translation, suggested_translation) do + %Movement.Operation{ + action: action, + text: suggested_translation.text, + key: Map.get(suggested_translation, :key, current_translation.key), + file_comment: Map.get(suggested_translation, :file_comment, current_translation.file_comment), + file_index: Map.get(suggested_translation, :file_index, current_translation.file_index), + document_id: Map.get(suggested_translation, :document_id, current_translation.document_id), + revision_id: Map.get(suggested_translation, :revision_id, current_translation.revision_id), + version_id: Map.get(suggested_translation, :version_id, current_translation.version_id), + value_type: Map.get(suggested_translation, :value_type, current_translation.value_type), + translation_id: Map.get(current_translation, :id), + previous_translation: from_translation(current_translation) + } + end + + defp from_translation(nil), do: %{} + defp from_translation(translation) when map_size(translation) == 0, do: %{} + + defp from_translation(translation) do + %{ + "proposed_text" => translation.proposed_text, + "corrected_text" => translation.corrected_text, + "conflicted_text" => translation.conflicted_text, + "conflicted" => translation.conflicted, + "removed" => translation.removed, + "value_type" => translation.value_type + } + end +end diff --git a/lib/movement/mappers/operations_stats.ex b/lib/movement/mappers/operations_stats.ex new file mode 100644 index 00000000..8627901f --- /dev/null +++ b/lib/movement/mappers/operations_stats.ex @@ -0,0 +1,14 @@ +defmodule Movement.Mappers.OperationsStats do + def map(operations) do + operations + |> Enum.group_by(&Map.get(&1, :action)) + |> Enum.map(&map_stat/1) + end + + defp map_stat({action, operations}) do + %{ + action: action, + count: length(operations) + } + end +end diff --git a/lib/movement/migration.ex b/lib/movement/migration.ex new file mode 100644 index 00000000..9e3a5874 --- /dev/null +++ b/lib/movement/migration.ex @@ -0,0 +1,5 @@ +defmodule Movement.Migration do + @type t :: {:ok, map} | {:error, map} + + @callback call(atom, map) :: t +end diff --git a/lib/movement/migration/conflict.ex b/lib/movement/migration/conflict.ex new file mode 100644 index 00000000..ec6df2bf --- /dev/null +++ b/lib/movement/migration/conflict.ex @@ -0,0 +1,55 @@ +defmodule Movement.Migration.Conflict do + @behaviour Movement.Migration + import Movement.EctoMigrationHelper + + def call(:correct, operation) do + Accent.OperationBatcher.batch(operation) + + update(operation.translation, %{ + corrected_text: operation.text, + conflicted: false + }) + end + + def call(:uncorrect, operation) do + update(operation.translation, %{ + conflicted_text: operation.previous_translation["conflicted_text"], + conflicted: true + }) + end + + def call(:on_corrected, operation) do + update(operation.translation, %{ + value_type: operation.value_type, + file_comment: operation.file_comment, + file_index: operation.file_index, + proposed_text: operation.text, + corrected_text: operation.text, + conflicted_text: operation.previous_translation["corrected_text"], + conflicted: true + }) + end + + def call(:on_slave, operation) do + update(operation.translation, %{ + value_type: operation.value_type, + file_comment: operation.file_comment, + file_index: operation.file_index, + corrected_text: operation.text, + conflicted_text: operation.previous_translation["conflicted_text"], + conflicted: true + }) + end + + def call(:on_proposed, operation) do + update(operation.translation, %{ + value_type: operation.value_type, + file_comment: operation.file_comment, + file_index: operation.file_index, + proposed_text: operation.text, + corrected_text: operation.text, + conflicted_text: operation.previous_translation["corrected_text"], + conflicted: true + }) + end +end diff --git a/lib/movement/migration/rollback.ex b/lib/movement/migration/rollback.ex new file mode 100644 index 00000000..76e74148 --- /dev/null +++ b/lib/movement/migration/rollback.ex @@ -0,0 +1,27 @@ +defmodule Movement.Migration.Rollback do + @behaviour Movement.Migration + + import Movement.EctoMigrationHelper + + alias Accent.PreviousTranslation + + def call(:new, operation) do + update(operation, %{rollbacked: true}) + update(operation.translation, %{removed: true}) + end + + def call(:remove, operation) do + update(operation, %{rollbacked: true}) + update(operation.translation, %{removed: false}) + end + + def call(:restore, operation) do + update(operation, %{rollbacked: true}) + update(operation.translation, PreviousTranslation.to_translation(operation.previous_translation)) + end + + def call(:rollback, operation) do + update(operation, %{rollbacked: false}) + update(operation.translation, PreviousTranslation.to_translation(operation.previous_translation)) + end +end diff --git a/lib/movement/migration/translation.ex b/lib/movement/migration/translation.ex new file mode 100644 index 00000000..f620aa23 --- /dev/null +++ b/lib/movement/migration/translation.ex @@ -0,0 +1,93 @@ +defmodule Movement.Migration.Translation do + @behaviour Movement.Migration + + import Movement.EctoMigrationHelper + + alias Accent.{PreviousTranslation, Translation, Operation} + + def call(:update_proposed, operation) do + operation.translation + |> update(%{ + proposed_text: operation.text + }) + end + + def call(:update, operation) do + Accent.OperationBatcher.batch(operation) + + operation.translation + |> update(%{ + value_type: operation.value_type, + corrected_text: operation.text, + conflicted_text: operation.previous_translation["corrected_text"] + }) + end + + def call(:remove, operation) do + update(operation.translation, %{removed: true}) + end + + def call(:renew, operation) do + new_translation = %{ + proposed_text: operation.text, + corrected_text: operation.text, + conflicted: true, + removed: false + } + + update(operation, %{rollbacked: false}) + update(operation.translation, new_translation) + end + + def call(:new, operation) do + id = Ecto.UUID.generate() + + translation = %Translation{ + id: id, + key: operation.key, + proposed_text: operation.text, + corrected_text: operation.text, + conflicted: is_nil(operation.version_id), + value_type: operation.value_type, + file_index: operation.file_index, + file_comment: operation.file_comment, + removed: Map.get(operation.previous_translation, "removed", false), + revision_id: operation.revision_id, + document_id: operation.document_id, + version_id: operation.version_id + } + + insert(translation) + update(operation, %{translation_id: id}) + end + + def call(:version_new, operation) do + id = Ecto.UUID.generate() + + translation = %Translation{ + id: id, + key: operation.key, + proposed_text: operation.text, + corrected_text: operation.text, + conflicted: false, + value_type: operation.value_type, + file_index: operation.file_index, + file_comment: operation.file_comment, + removed: Map.get(operation.previous_translation, "removed", false), + revision_id: operation.revision_id, + document_id: operation.document_id, + version_id: operation.version_id, + source_translation_id: operation.translation_id + } + + version_operation = Operation.copy(operation, %{action: "add_to_version", translation_id: id}) + + insert(translation) + insert(version_operation) + end + + def call(:restore, operation) do + update(operation, %{rollbacked: false}) + update(operation.translation, PreviousTranslation.to_translation(operation.previous_translation)) + end +end diff --git a/lib/movement/migrator.ex b/lib/movement/migrator.ex new file mode 100644 index 00000000..9078a7da --- /dev/null +++ b/lib/movement/migrator.ex @@ -0,0 +1,74 @@ +defmodule Movement.Migrator do + @moduledoc """ + Route migration to the module which will execute it or return + a value without a function call. + + Using a simple DSL with an `up` and `down` function, it creates functions in the same + fashion as the `Plug` library. + + Module use to execute operation should implement the `Migration` behaviour. + + ## Exemple + + # Given an `up` statement: + up :correct_conflict, Migration.Conflict, :correct + # And a function call on Migrator + Migrator.up(:correct_conflict, operation) + + This will call `Accent.Migrator.Migration.Conflict.call(:correct, operation)` where + operation is the same operation object passed to `Migrator.up/2`. + """ + + import Movement.Migrator.Macros + alias Movement.Migration.{Conflict, Translation, Rollback} + + def up(operations) when is_list(operations), do: Enum.map(operations, &up/1) + def down(operations) when is_list(operations), do: Enum.map(operations, &down/1) + + # Noop + up(:noop, {:ok, :noop}) + down(:noop, {:ok, :noop}) + + # Autocorrect + up(:autocorrect, {:ok, :autocorrect}) + down(:autocorrect, {:ok, :autocorrect}) + + # Conflicts + up(:correct_conflict, Conflict, :correct) + up(:uncorrect_conflict, Conflict, :uncorrect) + up(:conflict_on_proposed, Conflict, :on_proposed) + up(:merge_on_proposed, Conflict, :on_proposed) + up(:merge_on_proposed_force, Conflict, :on_proposed) + up(:conflict_on_slave, Conflict, :on_slave) + up(:conflict_on_corrected, Conflict, :on_corrected) + up(:merge_on_corrected, Conflict, :on_corrected) + up(:merge_on_corrected_force, Conflict, :on_proposed) + + # Translations + up(:remove, Translation, :remove) + up(:update, Translation, :update) + up(:update_proposed, Translation, :update_proposed) + up(:version_new, Translation, :version_new) + up(:new, Translation, :new) + up(:renew, Translation, :renew) + + # Rollback + up(:rollback, Rollback, :restore) + + down(:new, Rollback, :new) + down(:renew, Rollback, :new) + down(:remove, Rollback, :remove) + + down(:update, Rollback, :restore) + down(:update_proposed, Rollback, :restore) + down(:conflict_on_slave, Rollback, :restore) + down(:conflict_on_proposed, Rollback, :restore) + down(:conflict_on_corrected, Rollback, :restore) + down(:merge_on_proposed_force, Rollback, :restore) + down(:merge_on_proposed, Rollback, :restore) + down(:merge_on_corrected, Rollback, :restore) + down(:correct_conflict, Rollback, :restore) + down(:uncorrect_conflict, Rollback, :restore) + + down(:rollback, Rollback, :rollback) +end diff --git a/lib/movement/migrator_macros.ex b/lib/movement/migrator_macros.ex new file mode 100644 index 00000000..b9863b9b --- /dev/null +++ b/lib/movement/migrator_macros.ex @@ -0,0 +1,37 @@ +defmodule Movement.Migrator.Macros do + defmacro up(action, value) when is_tuple(value) do + quote do + def up(operation = %{action: unquote(to_string(action))}), do: unquote(value) + end + end + + defmacro up(action, module, function) do + module = Macro.expand(module, __CALLER__) + + quote do + def up(operation = %{action: unquote(to_string(action))}) do + {:ok, result} = unquote(module).call(unquote(function), operation) + + result + end + end + end + + defmacro down(action, value) when is_tuple(value) do + quote do + def down(operation = %{action: unquote(to_string(action))}), do: unquote(value) + end + end + + defmacro down(action, module, function) do + module = Macro.expand(module, __CALLER__) + + quote do + def down(operation = %{action: unquote(to_string(action))}) do + {:ok, result} = unquote(module).call(unquote(function), operation) + + result + end + end + end +end diff --git a/lib/movement/operation.ex b/lib/movement/operation.ex new file mode 100644 index 00000000..f14cb095 --- /dev/null +++ b/lib/movement/operation.ex @@ -0,0 +1,19 @@ +defmodule Movement.Operation do + defstruct action: nil, + key: nil, + text: nil, + file_comment: nil, + file_index: 0, + value_type: nil, + batch: false, + translation_id: nil, + rollbacked_operation_id: nil, + batch_operation_id: nil, + revision_id: nil, + version_id: nil, + document_id: nil, + project_id: nil, + previous_translation: %{} + + @type t :: %__MODULE__{} +end diff --git a/lib/movement/persister.ex b/lib/movement/persister.ex new file mode 100644 index 00000000..358e3d9c --- /dev/null +++ b/lib/movement/persister.ex @@ -0,0 +1,3 @@ +defmodule Movement.Persister do + @callback persist(Movement.Context.t()) :: {:ok, list} | {:error, list} +end diff --git a/lib/movement/persisters/base.ex b/lib/movement/persisters/base.ex new file mode 100644 index 00000000..c20c9ef6 --- /dev/null +++ b/lib/movement/persisters/base.ex @@ -0,0 +1,104 @@ +defmodule Movement.Persisters.Base do + require Ecto.Query + + alias Movement.Migrator + alias Movement.Mappers.OperationsStats, as: StatMapper + alias Accent.{Repo, Operation} + + @spec execute(Movement.Context.t()) :: {Movement.Context.t(), [Operation.t()]} + def execute(context = %Movement.Context{operations: []}), do: {context, []} + + def execute(context = %Movement.Context{assigns: assigns = %{batch_action: action}}) when is_binary(action) do + stats = StatMapper.map(context.operations) + + batch_operation = + %Operation{action: action, batch: true, user_id: assigns[:user_id]} + |> assign_document(assigns[:document]) + |> assign_project(assigns[:project]) + |> assign_revision(assigns[:revision]) + |> assign_version(assigns[:version]) + |> Map.put(:stats, stats) + |> Repo.insert!() + + context + |> Movement.Context.assign(:batch_operation, batch_operation) + |> Movement.Context.assign(:batch_action, nil) + |> execute() + end + + def execute(context) do + context + |> persist_operations() + |> migrate_up_operations() + end + + @spec rollback(Movement.Context.t()) :: {Movement.Context.t(), [Operation.t()]} + def rollback(%Movement.Context{assigns: %{operation: %{action: "rollback"}}}), do: Repo.rollback(:cannot_rollback_rollback) + def rollback(context = %Movement.Context{operations: []}), do: {context, []} + + def rollback(context) do + context + |> persist_operations() + |> migrate_down_operations() + end + + defp persist_operations(context = %Movement.Context{assigns: assigns}) do + operations = + Enum.map(context.operations, fn operation -> + operation + |> Map.put(:user_id, assigns[:user_id]) + |> Map.put(:inserted_at, NaiveDateTime.utc_now()) + |> Map.put(:updated_at, NaiveDateTime.utc_now()) + |> assign_project(assigns[:project]) + |> assign_batch_operation(assigns[:batch_operation]) + |> assign_document(assigns[:document]) + |> assign_revision(assigns[:revision]) + |> assign_version(assigns[:version]) + |> Map.from_struct() + end) + + {_count, operations} = Repo.insert_all(Operation, operations, returning: true) + + operations = Repo.preload(operations, :translation) + + %{context | operations: operations} + end + + defp migrate_up_operations(context = %Movement.Context{operations: operations}) do + {context, Migrator.up(operations)} + end + + defp migrate_down_operations(context = %Movement.Context{assigns: %{operation: operation = %{batch: true}}}) do + operations = + operation + |> Ecto.assoc(:operations) + |> Repo.all() + |> Repo.preload(:translation) + |> Migrator.down() + + {context, operations} + end + + defp migrate_down_operations(context = %Movement.Context{assigns: %{operation: operation}}) do + operation = Repo.preload(operation, :translation) + + {context, Migrator.down(operation)} + end + + defp assign_project(operation, nil), do: operation + defp assign_project(operation, project), do: %{operation | project_id: project.id} + + defp assign_batch_operation(operation, nil), do: operation + defp assign_batch_operation(operation, batch_operation), do: %{operation | batch_operation_id: batch_operation.id} + + defp assign_document(operation, nil), do: operation + defp assign_document(operation, document), do: %{operation | document_id: document.id} + + defp assign_revision(operation, nil), do: operation + defp assign_revision(operation = %{revision_id: revision_id}, _revision) when not is_nil(revision_id), do: operation + defp assign_revision(operation, revision), do: %{operation | revision_id: revision.id} + + defp assign_version(operation, nil), do: operation + defp assign_version(operation = %{version_id: version_id}, _version) when not is_nil(version_id), do: operation + defp assign_version(operation, version), do: %{operation | version_id: version.id} +end diff --git a/lib/movement/persisters/document_delete.ex b/lib/movement/persisters/document_delete.ex new file mode 100644 index 00000000..eb6b6617 --- /dev/null +++ b/lib/movement/persisters/document_delete.ex @@ -0,0 +1,16 @@ +defmodule Movement.Persisters.DocumentDelete do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + + @batch_action "document_delete" + + def persist(context) do + Repo.transaction(fn -> + context + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end +end diff --git a/lib/movement/persisters/new_slave.ex b/lib/movement/persisters/new_slave.ex new file mode 100644 index 00000000..f599b0b6 --- /dev/null +++ b/lib/movement/persisters/new_slave.ex @@ -0,0 +1,36 @@ +defmodule Movement.Persisters.NewSlave do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + alias Accent.{Repo, Revision} + + @batch_action "new_slave" + + def persist(context) do + Repo.transaction(fn -> + context + |> assign_new_revision() + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end + + defp assign_new_revision(context = %Movement.Context{assigns: assigns}) do + %Revision{} + |> Revision.changeset(%{ + "project_id" => assigns[:project].id, + "language_id" => assigns[:language].id, + "master_revision_id" => assigns[:master_revision].id, + "master" => false + }) + |> Repo.insert() + |> case do + {:ok, revision} -> + Movement.Context.assign(context, :revision, revision) + + {:error, changeset} -> + Repo.rollback(changeset) + end + end +end diff --git a/lib/movement/persisters/new_version.ex b/lib/movement/persisters/new_version.ex new file mode 100644 index 00000000..df596836 --- /dev/null +++ b/lib/movement/persisters/new_version.ex @@ -0,0 +1,36 @@ +defmodule Movement.Persisters.NewVersion do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + alias Accent.{Repo, Version} + + @batch_action "create_version" + + def persist(context) do + Repo.transaction(fn -> + context + |> assign_new_version() + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end + + defp assign_new_version(context = %Movement.Context{assigns: assigns}) do + %Version{} + |> Version.changeset(%{ + "project_id" => assigns[:project].id, + "user_id" => assigns[:user_id], + "name" => assigns[:name], + "tag" => assigns[:tag] + }) + |> Repo.insert() + |> case do + {:ok, version} -> + Movement.Context.assign(context, :version, version) + + {:error, changeset} -> + Repo.rollback(changeset) + end + end +end diff --git a/lib/movement/persisters/project_sync.ex b/lib/movement/persisters/project_sync.ex new file mode 100644 index 00000000..bf3806dd --- /dev/null +++ b/lib/movement/persisters/project_sync.ex @@ -0,0 +1,50 @@ +defmodule Movement.Persisters.ProjectSync do + @behaviour Movement.Persister + + import Movement.Context, only: [assign: 3] + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + alias Accent.{Repo, Project} + + @batch_action "sync" + + def persist(context = %Movement.Context{operations: []}), do: {:ok, {context, []}} + + def persist(context) do + Repo.transaction(fn -> + context + |> persist_document() + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + |> case do + {context, operations} -> + context.assigns[:project] + |> Project.changeset(%{"last_synced_at" => NaiveDateTime.utc_now()}) + |> Repo.update() + + {context, operations} + end + end) + end + + defp persist_document(context = %Movement.Context{assigns: %{document_update: document_update, document: document = %{id: id}}}) when not is_nil(id) do + document = + document + |> Accent.Document.changeset(document_update) + |> Repo.update!() + + assign(context, :document, document) + end + + defp persist_document(context = %Movement.Context{assigns: %{document: %{id: id}}}) when not is_nil(id), do: context + + defp persist_document(context = %Movement.Context{assigns: %{document: document}}) do + document = + document + |> Accent.Document.changeset(%{}) + |> Repo.insert!() + + assign(context, :document, document) + end +end diff --git a/lib/movement/persisters/revision_correct_all.ex b/lib/movement/persisters/revision_correct_all.ex new file mode 100644 index 00000000..81bf6eca --- /dev/null +++ b/lib/movement/persisters/revision_correct_all.ex @@ -0,0 +1,16 @@ +defmodule Movement.Persisters.RevisionCorrectAll do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + + @batch_action "correct_all" + + def persist(context) do + Repo.transaction(fn -> + context + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end +end diff --git a/lib/movement/persisters/revision_merge.ex b/lib/movement/persisters/revision_merge.ex new file mode 100644 index 00000000..60b9058b --- /dev/null +++ b/lib/movement/persisters/revision_merge.ex @@ -0,0 +1,16 @@ +defmodule Movement.Persisters.RevisionMerge do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + + @batch_action "merge" + + def persist(context) do + Repo.transaction(fn -> + context + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end +end diff --git a/lib/movement/persisters/revision_uncorrect_all.ex b/lib/movement/persisters/revision_uncorrect_all.ex new file mode 100644 index 00000000..e82af335 --- /dev/null +++ b/lib/movement/persisters/revision_uncorrect_all.ex @@ -0,0 +1,16 @@ +defmodule Movement.Persisters.RevisionUncorrectAll do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + + @batch_action "uncorrect_all" + + def persist(context) do + Repo.transaction(fn -> + context + |> Movement.Context.assign(:batch_action, @batch_action) + |> BasePersister.execute() + end) + end +end diff --git a/lib/movement/persisters/rollback.ex b/lib/movement/persisters/rollback.ex new file mode 100644 index 00000000..37cff85c --- /dev/null +++ b/lib/movement/persisters/rollback.ex @@ -0,0 +1,25 @@ +defmodule Movement.Persisters.Rollback do + @behaviour Movement.Persister + + alias Accent.Repo + alias Movement.Persisters.Base, as: BasePersister + alias Accent.{Repo, Operation} + + def persist(%Movement.Context{operations: []}), do: {:ok, []} + + def persist(context) do + Repo.transaction(fn -> + context + |> update_rollbacked_operation() + |> BasePersister.rollback() + end) + end + + defp update_rollbacked_operation(context = %Movement.Context{assigns: %{operation: operation}}) do + operation + |> Operation.changeset(%{updated_at: NaiveDateTime.utc_now(), rollbacked: true}) + |> Repo.update() + + context + end +end diff --git a/lib/movement/suggested_translation.ex b/lib/movement/suggested_translation.ex new file mode 100644 index 00000000..10aa5267 --- /dev/null +++ b/lib/movement/suggested_translation.ex @@ -0,0 +1,4 @@ +defmodule Movement.SuggestedTranslation do + @enforce_keys ~w(text key)a + defstruct ~w(text key file_comment file_index value_type revision_id)a +end diff --git a/lib/movement/translation_comparer.ex b/lib/movement/translation_comparer.ex new file mode 100644 index 00000000..d2d8b2b6 --- /dev/null +++ b/lib/movement/translation_comparer.ex @@ -0,0 +1,118 @@ +defmodule Movement.TranslationComparer do + # All action types and minus "conflict_on_slave" which can only be added by SlaveConflictBuilder. + @noop "noop" + @autocorrect "autocorrect" + @update_proposed "update_proposed" + @conflict_on_proposed "conflict_on_proposed" + @conflict_on_corrected "conflict_on_corrected" + @new "new" + @renew "renew" + @remove "remove" + + @doc """ + Receives a translation marked to be removed + + ## Examples + + iex> Movement.TranslationComparer.compare(%{marked_as_removed: true}, "test") + {"remove", nil} + """ + def compare(%{marked_as_removed: true}, _text), do: {@remove, nil} + + @doc """ + Receives a removed translation + + ## Examples + + iex> Movement.TranslationComparer.compare(%{removed: true}, "test") + {"renew", "test"} + """ + def compare(%{removed: true}, text), do: {@renew, text} + + @doc """ + Receives a translation with a corrected text, + where the corrected text is not equal to text + and proposed text is equal to text + + ## Examples + + iex> Movement.TranslationComparer.compare(%{proposed_text: "Hello", corrected_text: "Hi"}, "Hello") + {"autocorrect", "Hi"} + """ + def compare(%{proposed_text: proposed, corrected_text: corrected}, text) + when proposed == text and corrected != text, + do: {@autocorrect, corrected} + + @doc """ + Receives a translation with a corrected text, + where the corrected text is equal to text + + ## Examples + + iex> Movement.TranslationComparer.compare(%{proposed_text: "Hi", corrected_text: "Hi"}, "Hi") + {"noop", "Hi"} + """ + def compare(%{corrected_text: corrected, proposed_text: proposed}, text) + when proposed == text and corrected == text, + do: {@noop, text} + + @doc """ + Receives a translation with a corrected text, + where the corrected text is equal to text + + ## Examples + + iex> Movement.TranslationComparer.compare(%{proposed_text: "Hello", corrected_text: "Hi"}, "Hi") + {"update_proposed", "Hi"} + """ + def compare(%{corrected_text: corrected}, text) + when corrected == text, + do: {@update_proposed, text} + + @doc """ + Receives a translation with no corrected text, + where the proposed text is not equal to text + + ## Examples + + iex> Movement.TranslationComparer.compare(%{proposed_text: "Hello", corrected_text: "Hello"}, "Hi") + {"conflict_on_proposed", "Hi"} + """ + def compare(%{proposed_text: proposed, corrected_text: corrected}, text) + when proposed != text and corrected == proposed, + do: {@conflict_on_proposed, text} + + @doc """ + Receives a translation with corrected text, + where the proposed text is not equal to text + and the corrected text is not equal to text + + ## Examples + + iex> Movement.TranslationComparer.compare(%{proposed_text: "Hello", corrected_text: "Hi"}, "Welcome") + {"conflict_on_corrected", "Welcome"} + """ + def compare(%{proposed_text: proposed, corrected_text: corrected}, text) + when proposed != text and corrected != text, + do: {@conflict_on_corrected, text} + + @doc """ + No condition matches + + ## Examples + + iex> Movement.TranslationComparer.compare(%{}, "Welcome") + {"new", "Welcome"} + """ + def compare(%{}, text), do: {@new, text} + + @doc """ + Nil translation + + ## Examples + + iex> Movement.TranslationComparer.compare(nil, "Welcome") + {"new", "Welcome"} + """ + def compare(nil, text), do: {@new, text} +end diff --git a/lib/utils/pretty_float.ex b/lib/utils/pretty_float.ex new file mode 100644 index 00000000..819fbca0 --- /dev/null +++ b/lib/utils/pretty_float.ex @@ -0,0 +1,20 @@ +defmodule Accent.PrettyFloat do + @moduledoc """ + Pretty prints a float into either a float or an integer. + If the float ends with .0, it returns an integer. + + This is used to have a pretty percentage output. + + ## Examples + + iex> Accent.PrettyFloat.convert(2.0) + 2 + iex> Accent.PrettyFloat.convert(2.2) + 2.2 + iex> Accent.PrettyFloat.convert(28) + 28 + """ + + def convert(float) when trunc(float) == float, do: trunc(float) + def convert(float), do: float +end diff --git a/lib/utils/secure_random.ex b/lib/utils/secure_random.ex new file mode 100644 index 00000000..480f916f --- /dev/null +++ b/lib/utils/secure_random.ex @@ -0,0 +1,42 @@ +defmodule Accent.Utils.SecureRandom do + use Bitwise + + @default_length 16 + + @doc """ + Returns random Base64 encoded string. + ## Examples + + iex> Accent.Utils.SecureRandom.base64 + "rm/JfqH8Y+Jd7m5SHTHJoA==" + iex> Accent.Utils.SecureRandom.base64(8) + "2yDtUyQ5Xws=" + """ + def base64(length \\ @default_length) do + length + |> random_bytes() + |> :base64.encode_to_string() + |> to_string + end + + def urlsafe_base64(length \\ @default_length) do + length + |> base64() + |> String.replace(~r/[\n\=]/, "") + |> String.replace(~r/\+/, "-") + |> String.replace(~r/\//, "_") + end + + @doc """ + Returns random bytes. + + ## Examples + iex> Accent.Utils.SecureRandom.random_bytes + <<202, 104, 227, 197, 25, 7, 132, 73, 92, 186, 242, 13, 170, 115, 135, 7>> + iex> Accent.Utils.SecureRandom.random_bytes(8) + <<231, 123, 252, 174, 156, 112, 15, 29>> + """ + def random_bytes(length \\ @default_length) do + :crypto.strong_rand_bytes(length) + end +end diff --git a/lib/web/channels/project_channel.ex b/lib/web/channels/project_channel.ex new file mode 100644 index 00000000..b1ebd296 --- /dev/null +++ b/lib/web/channels/project_channel.ex @@ -0,0 +1,15 @@ +defmodule Accent.ProjectChannel do + use Phoenix.Channel + + alias Accent.Project + + import Canada, only: [can?: 2] + + def join("projects:" <> project_id, _params, socket) do + if socket.assigns[:user] |> can?(show_project(%Project{id: project_id})) do + {:ok, socket} + else + {:error, %{reason: "unauthorized"}} + end + end +end diff --git a/lib/web/channels/user_socket.ex b/lib/web/channels/user_socket.ex new file mode 100644 index 00000000..be60ba2a --- /dev/null +++ b/lib/web/channels/user_socket.ex @@ -0,0 +1,19 @@ +defmodule Accent.UserSocket do + use Phoenix.Socket + + alias Accent.{User, UserAuthFetcher} + + channel("projects:*", Accent.ProjectChannel) + transport(:websocket, Phoenix.Transports.WebSocket, timeout: 45_000) + + def connect(%{"token" => token}, socket) do + case UserAuthFetcher.fetch(token) do + user = %User{} -> {:ok, assign(socket, :user, user)} + nil -> :error + end + end + + def connect(_params, _socket), do: :error + + def id(socket), do: "users:#{socket.assigns[:user].id}" +end diff --git a/lib/web/controllers/authentication_controller.ex b/lib/web/controllers/authentication_controller.ex new file mode 100644 index 00000000..58d55220 --- /dev/null +++ b/lib/web/controllers/authentication_controller.ex @@ -0,0 +1,43 @@ +defmodule Accent.AuthenticationController do + use Plug.Builder + + alias Accent.UserRemote.Authenticator + + import Phoenix.Controller, only: [json: 2] + + plug(:fetch_authentication) + plug(:create) + + def create(conn = %{assigns: %{user: user, token: token}}, _) do + conn + |> json(%{ + token: token.token, + user: %{ + id: user.id, + email: user.email, + picture_url: user.picture_url, + fullname: user.fullname + } + }) + end + + def create(conn, _) do + conn + |> put_status(:unauthorized) + |> json(%{error: conn.assigns[:error]}) + end + + defp fetch_authentication(conn = %{params: %{"uid" => uid, "provider" => provider}}, _) do + case Authenticator.authenticate(provider, uid) do + {:ok, user, token} -> + conn + |> assign(:user, user) + |> assign(:token, token) + + {:error, error} -> + assign(conn, :error, error) + end + end + + defp fetch_authentication(conn, _), do: assign(conn, :error, "Invalid params") +end diff --git a/lib/web/controllers/badge_controller.ex b/lib/web/controllers/badge_controller.ex new file mode 100644 index 00000000..05cb94e4 --- /dev/null +++ b/lib/web/controllers/badge_controller.ex @@ -0,0 +1,33 @@ +defmodule Accent.BadgeController do + use Phoenix.Controller + + import Canary.Plugs + + alias Accent.BadgeGenerator + alias Accent.Project + + @svg_content_type "image/svg+xml" + + plug(:load_resource, model: Project, preload: [:revisions]) + plug(:fetch_badge) + + def fetch_badge(conn, _) do + conn.assigns[:project] + |> BadgeGenerator.generate(conn.private[:phoenix_action]) + |> case do + {:ok, badge} -> assign(conn, :badge, badge) + {:error, _} -> conn |> send_resp(500, "internal server error") |> halt() + end + end + + def percentage_reviewed_count(conn, _params), do: send_badge_resp(conn) + def conflicts_count(conn, _params), do: send_badge_resp(conn) + def reviewed_count(conn, _params), do: send_badge_resp(conn) + def translations_count(conn, _params), do: send_badge_resp(conn) + + defp send_badge_resp(conn) do + conn + |> put_resp_content_type(@svg_content_type) + |> send_resp(200, conn.assigns[:badge]) + end +end diff --git a/lib/web/controllers/error_controller.ex b/lib/web/controllers/error_controller.ex new file mode 100644 index 00000000..44a6ad30 --- /dev/null +++ b/lib/web/controllers/error_controller.ex @@ -0,0 +1,15 @@ +defmodule Accent.ErrorController do + import Plug.Conn + + def handle_unauthorized(conn) do + conn + |> send_resp(:unauthorized, "Unauthorized") + |> halt + end + + def handle_not_found(conn) do + conn + |> send_resp(:not_found, "Not found") + |> halt + end +end diff --git a/lib/web/controllers/export_controller.ex b/lib/web/controllers/export_controller.ex new file mode 100644 index 00000000..949954e0 --- /dev/null +++ b/lib/web/controllers/export_controller.ex @@ -0,0 +1,142 @@ +defmodule Accent.ExportController do + use Plug.Builder + + import Canary.Plugs + import Accent.Plugs.RevisionIdFromProjectLanguage + + alias Accent.Scopes.Translation, as: Scope + alias Accent.Scopes.Document, as: DocumentScope + alias Accent.Scopes.Version, as: VersionScope + alias Accent.{Repo, Project, Language, Document, Revision, Translation, Version} + + plug(Plug.Assign, %{canary_action: :export_revision}) + plug(:load_resource, model: Project, id_name: "project_id") + plug(:load_resource, model: Language, id_name: "language", id_field: "slug") + plug(:fetch_revision_id_from_project_language) + plug(:load_resource, model: Revision, id_name: "revision_id", preload: :language) + + plug(:fetch_order) + plug(:fetch_document) + plug(:fetch_version) + plug(:fetch_translations) + plug(:fetch_rendered_document) + + plug(:index) + + @doc """ + Export a revision to a file + + ## Endpoint + + GET /export + + ### Required params + - `project_id` + - `language` + - `document_format` + - `document_path` + + ### Optional params + - `order_by` + - `inline_render` + + ### Response + + #### Success + `200` - A file containing the rendered document. + + #### Error + - `404` Unknown revision id. + """ + def index(conn = %{query_params: %{"inline_render" => "true"}}, _) do + conn + |> put_resp_header("content-type", "text/plain") + |> send_resp(:ok, conn.assigns[:document].render) + end + + def index(conn, _) do + file = + [ + System.tmp_dir(), + Accent.Utils.SecureRandom.urlsafe_base64() + ] + |> Path.join() + + :ok = File.write(file, conn.assigns[:document].render) + + conn + |> put_resp_header("content-disposition", "inline; filename=\"#{conn.params["document_path"]}\"") + |> send_file(200, file) + end + + defp fetch_order(conn = %{params: %{"order_by" => ""}}, _), do: assign(conn, :order, "index") + defp fetch_order(conn = %{params: %{"order_by" => order}}, _), do: assign(conn, :order, order) + defp fetch_order(conn, _), do: assign(conn, :order, "index") + + defp fetch_translations(conn = %{assigns: %{document: document, order: order, revision: revision, version: version}}, _) do + translations = + Translation + |> Scope.active() + |> Scope.from_document(document.id) + |> Scope.from_revision(revision.id) + |> Scope.from_version(version && version.id) + |> Scope.parse_order(order) + |> Repo.all() + + assign(conn, :translations, translations) + end + + defp fetch_document(conn = %{params: params, assigns: %{project: project}}, _) do + Document + |> DocumentScope.from_project(project.id) + |> DocumentScope.from_path(extract_path_from_filename(params["document_path"])) + |> Repo.one() + |> case do + document = %Document{} -> + document = Map.put(document, :format, params["document_format"]) + assign(conn, :document, document) + + _ -> + Accent.ErrorController.handle_not_found(conn) + end + end + + defp fetch_version(conn = %{params: %{"version" => version_param}, assigns: %{project: project}}, _) do + Version + |> VersionScope.from_project(project.id) + |> VersionScope.from_tag(version_param) + |> Repo.one() + |> case do + version = %Version{} -> + assign(conn, :version, version) + + _ -> + Accent.ErrorController.handle_not_found(conn) + end + end + + defp fetch_version(conn, _), do: assign(conn, :version, nil) + + defp fetch_rendered_document(conn = %{assigns: %{translations: translations, revision: revision, document: document}}, _) do + %{render: render} = + Accent.TranslationsRenderer.render(%{ + translations: translations, + document_format: document.format, + document_top_of_the_file_comment: document.top_of_the_file_comment, + document_header: document.header, + document_locale: revision.language.slug + }) + + document = Map.put(document, :render, render) + + assign(conn, :document, document) + end + + defp extract_path_from_filename(nil), do: nil + + defp extract_path_from_filename(filename) do + filename + |> String.split(".", parts: 2) + |> Enum.at(0) + end +end diff --git a/lib/web/controllers/merge_controller.ex b/lib/web/controllers/merge_controller.ex new file mode 100644 index 00000000..fad4b00e --- /dev/null +++ b/lib/web/controllers/merge_controller.ex @@ -0,0 +1,107 @@ +defmodule Accent.MergeController do + use Plug.Builder + + import Canary.Plugs + import Accent.Plugs.RevisionIdFromProjectLanguage + + alias Movement.Builders.RevisionMerge, as: RevisionMergeBuilder + alias Movement.Persisters.RevisionMerge, as: RevisionMergePersister + alias Movement.Comparers.{MergeSmart, MergeForce, MergePassive} + + alias Accent.{ + Project, + Language, + Revision + } + + alias Accent.Hook.Context, as: HookContext + + plug(Plug.Assign, canary_action: :merge) + plug(:load_and_authorize_resource, model: Project, id_name: "project_id") + plug(Accent.Plugs.EnsureUnlockedFileOperations) + plug(:load_resource, model: Language, id_name: "language", id_field: "slug") + plug(:fetch_revision_id_from_project_language) + plug(:load_and_authorize_resource, model: Revision, id_name: "revision_id", preload: :language) + plug(Accent.Plugs.MovementContextParser) + plug(:assign_comparer) + plug(:create) + + @broadcaster Application.get_env(:accent, :hook_broadcaster) + + @doc """ + Create new merge for a project and a language + + ## Endpoint + + GET /merge + + ### Required params + - `project_id` + - `language_id` + - `file` + + ### Optional params + - `merge_type` (smart, force or passive), default: smart. + + ### Response + + #### Success + `200` - Ok. + + #### Error + `404` - Unknown revision + `422` - Invalid file + """ + def create(conn, _params) do + conn.assigns[:movement_context] + |> Movement.Context.assign(:revision, conn.assigns[:revision]) + |> Movement.Context.assign(:merge_type, conn.assigns[:merge_type]) + |> Movement.Context.assign(:user_id, conn.assigns[:current_user].id) + |> RevisionMergeBuilder.build() + |> RevisionMergePersister.persist() + |> case do + {:ok, {_context, []}} -> + send_resp(conn, :ok, "") + + {:ok, _} -> + @broadcaster.fanout(%HookContext{ + event: "merge", + project: conn.assigns[:project], + user: conn.assigns[:current_user], + payload: %{ + merge_type: conn.assigns[:merge_type], + language_name: conn.assigns[:revision].language.name + } + }) + + send_resp(conn, :ok, "") + + {:error, _reason} -> + send_resp(conn, :unprocessable_entity, "") + end + end + + defp assign_comparer(conn = %{params: %{"merge_type" => "force"}}, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergeForce.compare/2) + + assign(conn, :movement_context, context) + end + + defp assign_comparer(conn = %{params: %{"merge_type" => "passive"}}, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergePassive.compare/2) + + assign(conn, :movement_context, context) + end + + defp assign_comparer(conn, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergeSmart.compare/2) + + assign(conn, :movement_context, context) + end +end diff --git a/lib/web/controllers/peek_controller.ex b/lib/web/controllers/peek_controller.ex new file mode 100644 index 00000000..2147e8d5 --- /dev/null +++ b/lib/web/controllers/peek_controller.ex @@ -0,0 +1,142 @@ +defmodule Accent.PeekController do + use Phoenix.Controller + + import Canary.Plugs + import Accent.Plugs.RevisionIdFromProjectLanguage + + alias Movement.Builders.ProjectSync, as: ProjectSyncBuilder + alias Movement.Builders.RevisionMerge, as: RevisionMergeBuilder + alias Movement.Comparers.Sync, as: SyncComparer + alias Movement.Comparers.{MergeSmart, MergeForce, MergePassive} + + alias Accent.{ + Project, + Revision, + Language + } + + alias Accent.Hook.Context, as: HookContext + + plug(Plug.Assign, [canary_action: :peek_merge] when action === :merge) + plug(Plug.Assign, [canary_action: :peek_sync] when action === :sync) + plug(:load_and_authorize_resource, model: Project, id_name: "project_id") + plug(:load_resource, model: Language, id_name: "language", id_field: "slug") + plug(:fetch_revision_id_from_project_language when action === :merge) + plug(:load_and_authorize_resource, model: Revision, id_name: "revision_id", preload: :language, only: [:peek_merge]) + plug(Accent.Plugs.MovementContextParser) + plug(:parse_merge_option when action in [:merge]) + + @broadcaster Application.get_env(:accent, :hook_broadcaster) + + @doc """ + Peek operations that would be created when doing a sync + + ## Endpoint + + GET /sync/peek + + ### Required params + - `project_id` + - `language_id` + - `file` + - `document_path` + - `document_format` + + ### Response + + #### Success + `200` - List of serialized operations grouped in master_operations and slaves_operations keys + + #### Error + `404` - Unknown project + """ + def sync(conn, _params) do + operations = + conn.assigns[:movement_context] + |> Movement.Context.assign(:project, conn.assigns[:project]) + |> Movement.Context.assign(:comparer, &SyncComparer.compare/2) + |> ProjectSyncBuilder.build() + |> Map.get(:operations) + |> Enum.group_by(&Map.get(&1, :revision_id)) + + @broadcaster.fanout(%HookContext{ + event: "peek_sync", + project: conn.assigns[:project], + user: conn.assigns[:current_user] + }) + + render(conn, "index.json", operations: operations) + end + + @doc """ + Peek operations that would be created when doing a merge + + ## Endpoint + + GET /merge/peek + + ### Required params + - `project_id` + - `language_id` + - `file` + - `document_path` + - `document_format` + + ### Optional params + - `merge_type` + + ### Response + + #### Success + `200` - List of serialized operations + + #### Error + `404` - Unknown revision + `422` - Invalid file + """ + def merge(conn, _params) do + operations = + conn.assigns[:movement_context] + |> Movement.Context.assign(:revision, conn.assigns[:revision]) + |> Movement.Context.assign(:merge_type, conn.assigns[:merge_type]) + |> RevisionMergeBuilder.build() + |> Map.get(:operations) + |> Enum.group_by(&Map.get(&1, :revision_id)) + + @broadcaster.fanout(%HookContext{ + event: "peek_merge", + project: conn.assigns[:project], + user: conn.assigns[:current_user], + payload: %{ + merge_type: conn.assigns[:merge_type], + language_name: conn.assigns[:revision].language.name + } + }) + + render(conn, "index.json", operations: operations) + end + + defp parse_merge_option(conn = %{params: %{"merge_type" => "force"}}, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergeForce.compare/2) + + assign(conn, :movement_context, context) + end + + defp parse_merge_option(conn = %{params: %{"merge_type" => "passive"}}, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergePassive.compare/2) + + assign(conn, :movement_context, context) + end + + defp parse_merge_option(conn, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &MergeSmart.compare/2) + + assign(conn, :movement_context, context) + end +end diff --git a/lib/web/controllers/sync_controller.ex b/lib/web/controllers/sync_controller.ex new file mode 100644 index 00000000..1ce57d9f --- /dev/null +++ b/lib/web/controllers/sync_controller.ex @@ -0,0 +1,77 @@ +defmodule Accent.SyncController do + use Plug.Builder + + import Canary.Plugs + + alias Movement.Builders.ProjectSync, as: SyncBuilder + alias Movement.Persisters.ProjectSync, as: SyncPersister + alias Movement.Comparers.Sync, as: SyncComparer + alias Accent.Project + alias Accent.Hook.Context, as: HookContext + + plug(Plug.Assign, canary_action: :sync) + plug(:load_and_authorize_resource, model: Project, id_name: "project_id") + plug(Accent.Plugs.EnsureUnlockedFileOperations) + plug(Accent.Plugs.MovementContextParser) + plug(:assign_comparer) + plug(:create) + + @broadcaster Application.get_env(:accent, :hook_broadcaster) + + @doc """ + Create new sync for a project + + ## Endpoint + + GET /sync + + ### Required params + - `project_id` + - `file` + - `document_path` + - `document_format` + + ### Response + + #### Success + `200` - Ok. + + #### Error + `404` - Unknown project + """ + def create(conn, _) do + conn.assigns[:movement_context] + |> Movement.Context.assign(:project, conn.assigns[:project]) + |> Movement.Context.assign(:user_id, conn.assigns[:current_user].id) + |> SyncBuilder.build() + |> SyncPersister.persist() + |> case do + {:ok, {_context, []}} -> + send_resp(conn, :ok, "") + + {:ok, {context, _operations}} -> + @broadcaster.fanout(%HookContext{ + event: "sync", + project: conn.assigns[:project], + user: conn.assigns[:current_user], + payload: %{ + batch_operation_stats: context.assigns[:batch_operation].stats, + document_path: context.assigns[:document].path + } + }) + + send_resp(conn, :ok, "") + + {:error, _reason} -> + send_resp(conn, :unprocessable_entity, "") + end + end + + defp assign_comparer(conn, _) do + context = + conn.assigns[:movement_context] + |> Movement.Context.assign(:comparer, &SyncComparer.compare/2) + + assign(conn, :movement_context, context) + end +end diff --git a/lib/web/controllers/webapp_controller.ex b/lib/web/controllers/webapp_controller.ex new file mode 100644 index 00000000..53a20889 --- /dev/null +++ b/lib/web/controllers/webapp_controller.ex @@ -0,0 +1,11 @@ +defmodule Accent.WebAppController do + use Plug.Builder + + plug(:index) + + def index(conn, _) do + conn + |> put_resp_header("content-type", "text/html; charset=utf-8") + |> Plug.Conn.send_file(200, "priv/static/webapp/index.html") + end +end diff --git a/lib/web/emails/create_comment_email.ex b/lib/web/emails/create_comment_email.ex new file mode 100644 index 00000000..f39bdf60 --- /dev/null +++ b/lib/web/emails/create_comment_email.ex @@ -0,0 +1,34 @@ +defmodule Accent.CreateCommentEmail do + use Bamboo.Phoenix, view: Accent.EmailView + + import Accent.EmailViewConfigHelper, only: [mailer_from: 0, x_smtpapi_header: 0] + + @spec create(list(String.t()), Accent.Comment.t()) :: Bamboo.Email.t() + def create(emails, comment) do + base_email() + |> to(emails) + |> mailer_subject(comment.translation.revision.project) + |> assign(:commenter, comment.user) + |> assign(:translation, comment.translation) + |> assign(:comment, comment) + |> assign(:translation_path, translation_path(comment.translation.revision.project, comment.translation)) + |> render(:create_comment) + end + + defp mailer_subject(email, project) do + email + |> subject(~s(Accent – New comment on "#{project.name}")) + end + + defp base_email do + new_email() + |> from({"Accent", mailer_from()}) + |> put_layout({Accent.EmailLayoutView, :index}) + |> add_x_smtpapi_header(x_smtpapi_header()) + end + + defp add_x_smtpapi_header(email, nil), do: email + defp add_x_smtpapi_header(email, header), do: put_header(email, "X-SMTPAPI", header) + + defp translation_path(project, translation), do: "/app/projects/#{project.id}/translations/#{translation.id}/conversation" +end diff --git a/lib/web/emails/project_invite_email.ex b/lib/web/emails/project_invite_email.ex new file mode 100644 index 00000000..6e0e97e0 --- /dev/null +++ b/lib/web/emails/project_invite_email.ex @@ -0,0 +1,31 @@ +defmodule Accent.ProjectInviteEmail do + use Bamboo.Phoenix, view: Accent.EmailView + + import Accent.EmailViewConfigHelper, only: [mailer_from: 0, x_smtpapi_header: 0] + + @spec create(String.t() | [String.t()], Accent.User.t(), Accent.Project.t()) :: Bamboo.Email.t() + def create(email_address, user, project) do + base_email() + |> to(email_address) + |> mailer_subject(project) + |> assign(:email, email_address) + |> assign(:project, project) + |> assign(:user, user) + |> render(:project_invite) + end + + defp mailer_subject(email, project) do + email + |> subject(~s(Accent – Invitation to collaborate on "#{project.name}")) + end + + defp base_email do + new_email() + |> from({"Accent", mailer_from()}) + |> put_layout({Accent.EmailLayoutView, :index}) + |> add_x_smtpapi_header(x_smtpapi_header()) + end + + defp add_x_smtpapi_header(email, nil), do: email + defp add_x_smtpapi_header(email, header), do: put_header(email, "X-SMTPAPI", header) +end diff --git a/lib/web/plugs/assign_current_user.ex b/lib/web/plugs/assign_current_user.ex new file mode 100644 index 00000000..73d0d933 --- /dev/null +++ b/lib/web/plugs/assign_current_user.ex @@ -0,0 +1,21 @@ +defmodule Accent.Plugs.AssignCurrentUser do + import Plug.Conn + + alias Accent.UserAuthFetcher + + def init(_), do: nil + + @doc """ + Takes a Plug.Conn and fetch the associated user giving the Authorization header. + It assigns nil if any of the steps fails. + """ + def call(conn, _opts) do + user = + conn + |> get_req_header("authorization") + |> Enum.at(0) + |> UserAuthFetcher.fetch() + + assign(conn, :current_user, user) + end +end diff --git a/lib/web/plugs/bot_params_injector.ex b/lib/web/plugs/bot_params_injector.ex new file mode 100644 index 00000000..fceae6cb --- /dev/null +++ b/lib/web/plugs/bot_params_injector.ex @@ -0,0 +1,33 @@ +defmodule Accent.Plugs.BotParamsInjector do + use Plug.Builder + + plug(:assign_project_id) + + @doc """ + If the current_user is the project’s bot, we automatically add the project id in the params. + This makes the param not required in the URL when making calls such as `sync` or `merge`. + """ + def assign_project_id(conn = %{assigns: %{current_user: user = %{bot: true}}}, _) do + case Enum.at(user.permissions, 0) do + {project_id, _} -> + project = %{"project_id" => project_id} + + conn + |> update_in([Access.key(:params)], &Map.merge(&1, project)) + |> update_in([Access.key(:params), "variables"], fn + nil -> project + variables -> Map.merge(variables, project) + end) + + _ -> + conn + |> send_resp(401, "Unauthorized") + |> halt() + end + end + + @doc """ + Fallback to doing nothing with the connection + """ + def assign_project_id(conn, _), do: conn +end diff --git a/lib/web/plugs/ensure_unlocked_file_operations.ex b/lib/web/plugs/ensure_unlocked_file_operations.ex new file mode 100644 index 00000000..a42f4a34 --- /dev/null +++ b/lib/web/plugs/ensure_unlocked_file_operations.ex @@ -0,0 +1,13 @@ +defmodule Accent.Plugs.EnsureUnlockedFileOperations do + import Plug.Conn + + def init(_), do: nil + + def call(conn, _) do + if conn.assigns[:project].locked_file_operations do + conn |> send_resp(:forbidden, "File operations are locked") |> halt + else + conn + end + end +end diff --git a/lib/web/plugs/graphql_context.ex b/lib/web/plugs/graphql_context.ex new file mode 100644 index 00000000..13bf1650 --- /dev/null +++ b/lib/web/plugs/graphql_context.ex @@ -0,0 +1,14 @@ +defmodule Accent.Plugs.GraphQLContext do + @behaviour Plug + + @type t :: %{context: map()} + + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _) do + conn + |> put_private(:absinthe, %{context: %{conn: conn}}) + end +end diff --git a/lib/web/plugs/movement_context_parser.ex b/lib/web/plugs/movement_context_parser.ex new file mode 100644 index 00000000..d3ba00b3 --- /dev/null +++ b/lib/web/plugs/movement_context_parser.ex @@ -0,0 +1,120 @@ +defmodule Accent.Plugs.MovementContextParser do + use Plug.Builder + + alias Accent.{Repo, Document} + alias Accent.Scopes.Document, as: DocumentScope + alias Langue.Formatter.Strings.Parser, as: StringsParser + alias Langue.Formatter.Rails.Parser, as: RailsParser + alias Langue.Formatter.Json.Parser, as: JsonParser + alias Langue.Formatter.SimpleJson.Parser, as: SimpleJsonParser + alias Langue.Formatter.Es6Module.Parser, as: Es6ModuleParser + alias Langue.Formatter.Android.Parser, as: AndroidParser + alias Langue.Formatter.JavaProperties.Parser, as: JavaPropertiesParser + alias Langue.Formatter.JavaPropertiesXml.Parser, as: JavaPropertiesXmlParser + alias Langue.Formatter.Gettext.Parser, as: GettextParser + alias Movement.Context + + plug(:validate_params) + plug(:assign_document_parser) + plug(:assign_document_path) + plug(:assign_document_format) + plug(:assign_document_locale) + plug(:assign_movement_context) + plug(:assign_movement_document) + plug(:assign_movement_entries) + + def validate_params(conn = %{params: %{"document_format" => _format, "file" => _file, "language" => _language}}, _), do: conn + def validate_params(conn, _), do: conn |> send_resp(:unprocessable_entity, "file, language and document_format are required") |> halt + + def assign_document_parser(conn = %{params: %{"document_format" => document_format}}, _) do + case parser_from_format(document_format) do + {:ok, parser} -> assign(conn, :document_parser, parser) + {:error, _reason} -> conn |> send_resp(:unprocessable_entity, "document_format is invalid") |> halt + end + end + + def assign_document_locale(conn = %{params: %{"language" => language}}, _) do + assign(conn, :document_locale, language) + end + + def assign_document_format(conn = %{params: %{"document_format" => format}}, _) do + assign(conn, :document_format, format) + end + + def assign_document_path(conn = %{params: %{"document_path" => path}}, _) when path !== "" and not is_nil(path) do + assign(conn, :document_path, extract_path_from_filename(path)) + end + + def assign_document_path(conn = %{params: %{"file" => file}}, _) do + assign(conn, :document_path, extract_path_from_filename(file.filename)) + end + + def assign_movement_context(conn, _) do + assign(conn, :movement_context, %Context{}) + end + + def assign_movement_document(conn = %{assigns: %{project: project, movement_context: context, document_path: path, document_format: format}}, _opts) do + document = + Document + |> DocumentScope.from_path(path) + |> DocumentScope.from_project(project.id) + |> Repo.one() + + case document do + nil -> + context = Context.assign(context, :document, %Document{project_id: project.id, path: path, format: format}) + assign(conn, :movement_context, context) + + _ -> + document = %{document | format: format} + context = Context.assign(context, :document, document) + assign(conn, :movement_context, context) + end + end + + def assign_movement_entries(conn = %{assigns: %{movement_context: context}, params: %{"file" => file}}, _) do + render = File.read!(file.path) + + conn + |> serializer_result(render) + |> case do + %Langue.Formatter.ParserResult{entries: entries, top_of_the_file_comment: comment, header: header} -> + document = %{context.assigns[:document] | top_of_the_file_comment: comment, header: header} + + context = + context + |> Context.assign(:document, document) + |> Context.assign(:document_update, %{top_of_the_file_comment: comment, header: header}) + |> Map.put(:render, render) + |> Map.put(:entries, entries) + + assign(conn, :movement_context, context) + + {:error, :invalid_file} -> + conn |> send_resp(:unprocessable_entity, "file cannot be parsed") |> halt + end + end + + defp serializer_result(conn, render) do + conn.assigns[:document_parser].(%Langue.Formatter.SerializerResult{render: render}) + catch + _ -> {:error, :invalid_file} + end + + defp extract_path_from_filename(filename) do + filename + |> String.split(".", parts: 2) + |> Enum.at(0) + end + + defp parser_from_format("strings"), do: {:ok, &StringsParser.parse/1} + defp parser_from_format("rails_yml"), do: {:ok, &RailsParser.parse/1} + defp parser_from_format("json"), do: {:ok, &JsonParser.parse/1} + defp parser_from_format("simple_json"), do: {:ok, &SimpleJsonParser.parse/1} + defp parser_from_format("android_xml"), do: {:ok, &AndroidParser.parse/1} + defp parser_from_format("es6_module"), do: {:ok, &Es6ModuleParser.parse/1} + defp parser_from_format("java_properties"), do: {:ok, &JavaPropertiesParser.parse/1} + defp parser_from_format("java_properties_xml"), do: {:ok, &JavaPropertiesXmlParser.parse/1} + defp parser_from_format("gettext"), do: {:ok, &GettextParser.parse/1} + defp parser_from_format(_), do: {:error, :unknown_parser} +end diff --git a/lib/web/plugs/revision_id_from_project_language.ex b/lib/web/plugs/revision_id_from_project_language.ex new file mode 100644 index 00000000..0b3f970e --- /dev/null +++ b/lib/web/plugs/revision_id_from_project_language.ex @@ -0,0 +1,16 @@ +defmodule Accent.Plugs.RevisionIdFromProjectLanguage do + alias Accent.Repo + alias Accent.Revision + + def fetch_revision_id_from_project_language(conn = %{assigns: %{language: language, project: project}}, _) do + case Repo.get_by(Revision, language_id: language.id, project_id: project.id) do + %Revision{id: id} -> + %{conn | params: Map.put(conn.params, "revision_id", id)} + + nil -> + conn + |> Plug.Conn.send_resp(:not_found, "") + |> Plug.Conn.halt() + end + end +end diff --git a/lib/web/plugs/sentry_user_context.ex b/lib/web/plugs/sentry_user_context.ex new file mode 100644 index 00000000..55e0b734 --- /dev/null +++ b/lib/web/plugs/sentry_user_context.ex @@ -0,0 +1,18 @@ +defmodule Accent.Plugs.SentryUserContext do + alias Accent.User + + def init(_), do: nil + + @doc """ + Takes some keys in the current_user assign to put it in Sentry’s context + """ + def call(conn = %{assigns: %{current_user: current_user = %User{}}}, _opts) do + current_user + |> Map.take([:id, :email, :fullname]) + |> Sentry.Context.set_user_context() + + conn + end + + def call(conn, _opts), do: conn +end diff --git a/lib/web/router.ex b/lib/web/router.ex new file mode 100644 index 00000000..0c19fad5 --- /dev/null +++ b/lib/web/router.ex @@ -0,0 +1,58 @@ +defmodule Accent.Router do + use Phoenix.Router + use Plug.ErrorHandler + use Sentry.Plug + + if Mix.env() == :dev do + forward("/emails", Bamboo.EmailPreviewPlug) + end + + pipeline :graphql do + plug(Accent.Plugs.AssignCurrentUser) + plug(Accent.Plugs.SentryUserContext) + plug(Accent.Plugs.BotParamsInjector) + plug(Accent.Plugs.GraphQLContext) + end + + forward("/graphiql", Absinthe.Plug.GraphiQL, schema: Accent.GraphQL.Schema) + + scope "/graphql" do + pipe_through(:graphql) + + forward("/", Absinthe.Plug, schema: Accent.GraphQL.Schema) + end + + pipeline :authenticate do + plug(Accent.Plugs.AssignCurrentUser) + plug(Accent.Plugs.SentryUserContext) + plug(Accent.Plugs.BotParamsInjector) + end + + scope "/", Accent do + pipe_through(:authenticate) + + post("/sync", SyncController, []) + post("/sync/peek", PeekController, :sync, as: :peek_sync) + post("/add-translations", MergeController, []) + post("/add-translations/peek", PeekController, :merge, as: :peek_add_translations) + post("/merge", MergeController, []) + post("/merge/peek", PeekController, :merge, as: :peek_merge) + + # File export + get("/export", ExportController, []) + end + + scope "/", Accent do + # Users + post("/auth", AuthenticationController, :create) + + get("/:id/percentage_reviewed_badge.svg", BadgeController, :percentage_reviewed_count) + get("/:id/reviewed_badge.svg", BadgeController, :reviewed_count) + get("/:id/conflicts_badge.svg", BadgeController, :conflicts_count) + get("/:id/translations_badge.svg", BadgeController, :translations_count) + end + + # Catch all route to serve the webapp from static dir + get("/", Accent.WebAppController, []) + get("/app*path", Accent.WebAppController, []) +end diff --git a/lib/web/templates/badge/reviewed.svg.eex b/lib/web/templates/badge/reviewed.svg.eex new file mode 100644 index 00000000..e361eec1 --- /dev/null +++ b/lib/web/templates/badge/reviewed.svg.eex @@ -0,0 +1 @@ +accentaccent98.87%98.87% diff --git a/lib/web/templates/email/create_comment.html.eex b/lib/web/templates/email/create_comment.html.eex new file mode 100644 index 00000000..627e2c56 --- /dev/null +++ b/lib/web/templates/email/create_comment.html.eex @@ -0,0 +1,4 @@ +

"><%= @commenter.email %> has commented on <%= @translation.key %>:

+

"><%= @comment.text %>

+ +

">You can reply here

diff --git a/lib/web/templates/email/create_comment.text.eex b/lib/web/templates/email/create_comment.text.eex new file mode 100644 index 00000000..6a8efc23 --- /dev/null +++ b/lib/web/templates/email/create_comment.text.eex @@ -0,0 +1,6 @@ +<%= @commenter.email %> has commented on <%= @translation.key %>: + +--- +<%= @comment.text %> + +You can reply here (<%= webapp_host() %><%= @translation_path %>) diff --git a/lib/web/templates/email/project_invite.html.eex b/lib/web/templates/email/project_invite.html.eex new file mode 100644 index 00000000..e8986e94 --- /dev/null +++ b/lib/web/templates/email/project_invite.html.eex @@ -0,0 +1,5 @@ +

"><%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>"

+ +

">If you already have an account (<%= @email %>), you can just login and you’ll see the project.

+ +

">If not, creating an account is as simple as logging in with the invited Google account (<%= @email %>) on <%= webapp_host() %>

diff --git a/lib/web/templates/email/project_invite.text.eex b/lib/web/templates/email/project_invite.text.eex new file mode 100644 index 00000000..5d370b84 --- /dev/null +++ b/lib/web/templates/email/project_invite.text.eex @@ -0,0 +1,5 @@ +<%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>". + +If you already have an account (<%= @email %>), you can just login (<%= webapp_host() %>) and you’ll see the project. + +If not, creating an account is as simple as logging in with the invited Google account (<%= @email %>) on <%= webapp_host() %> diff --git a/lib/web/templates/email_layout/index.html.eex b/lib/web/templates/email_layout/index.html.eex new file mode 100644 index 00000000..c264d549 --- /dev/null +++ b/lib/web/templates/email_layout/index.html.eex @@ -0,0 +1,21 @@ + + + + + + +
+ Accent + + <%= render @view_module, @view_template, assigns %> + +
" /> + + ">The Accent Team + +
+ + " href="<%= webapp_host() %>"><%= webapp_host() %> +
+ + diff --git a/lib/web/templates/email_layout/index.text.eex b/lib/web/templates/email_layout/index.text.eex new file mode 100644 index 00000000..209954e1 --- /dev/null +++ b/lib/web/templates/email_layout/index.text.eex @@ -0,0 +1,9 @@ +Accent +- + +<%= render @view_module, @view_template, assigns %> + +- +The Accent Team + +(<%= webapp_host() %>) diff --git a/lib/web/views/badge_view.ex b/lib/web/views/badge_view.ex new file mode 100644 index 00000000..3c62826b --- /dev/null +++ b/lib/web/views/badge_view.ex @@ -0,0 +1,3 @@ +defmodule Accent.BadgeView do + use Phoenix.View, root: "lib/web/templates" +end diff --git a/lib/web/views/email_layout_view.ex b/lib/web/views/email_layout_view.ex new file mode 100644 index 00000000..5689cc8d --- /dev/null +++ b/lib/web/views/email_layout_view.ex @@ -0,0 +1,13 @@ +defmodule Accent.EmailLayoutView do + use Phoenix.View, root: "lib/web/templates" + + import Accent.Router.Helpers, only: [static_url: 2] + import Accent.EmailViewStyleHelper + import Accent.EmailViewConfigHelper + + def logo_url do + Accent.Endpoint + |> static_url("/static/images/accent.png") + |> String.replace(~r/:\d+/, "") + end +end diff --git a/lib/web/views/email_view.ex b/lib/web/views/email_view.ex new file mode 100644 index 00000000..e80aa725 --- /dev/null +++ b/lib/web/views/email_view.ex @@ -0,0 +1,6 @@ +defmodule Accent.EmailView do + use Phoenix.View, root: "lib/web/templates" + + import Accent.EmailViewStyleHelper + import Accent.EmailViewConfigHelper +end diff --git a/lib/web/views/email_view_config_helper.ex b/lib/web/views/email_view_config_helper.ex new file mode 100644 index 00000000..ed8ff673 --- /dev/null +++ b/lib/web/views/email_view_config_helper.ex @@ -0,0 +1,9 @@ +defmodule Accent.EmailViewConfigHelper do + def x_smtpapi_header, do: config()[:x_smtpapi_header] + def mailer_from, do: config()[:mailer_from] + def webapp_host, do: config()[:webapp_host] + + defp config do + Application.get_env(:accent, Accent.Mailer) + end +end diff --git a/lib/web/views/email_view_style_helper.ex b/lib/web/views/email_view_style_helper.ex new file mode 100644 index 00000000..fcc77298 --- /dev/null +++ b/lib/web/views/email_view_style_helper.ex @@ -0,0 +1,34 @@ +defmodule Accent.EmailViewStyleHelper do + @default_link_styles [ + "font-family": ~s(Helvetica, Arial, sans-serif), + color: "#1ecd8d", + "text-decoration": "none" + ] + + @default_paragraph_styles [ + margin: "20px 0", + color: "#7b7b7b", + "font-family": ~s(Helvetica, Arial, sans-serif), + "line-height": "1.5" + ] + + def style(styles \\ []) do + format_styles(styles, []) + end + + def link_style(styles \\ []) do + format_styles(styles, @default_link_styles) + end + + def paragraph_style(styles \\ []) do + format_styles(styles, @default_paragraph_styles) + end + + defp format_styles(default_styles, styles) do + default_styles + |> Enum.concat(styles) + |> Enum.uniq_by(fn {key, _value} -> key end) + |> Enum.map(fn {key, value} -> "#{key}: #{value}" end) + |> Enum.join("; ") + end +end diff --git a/lib/web/views/error_view.ex b/lib/web/views/error_view.ex new file mode 100644 index 00000000..83d2027c --- /dev/null +++ b/lib/web/views/error_view.ex @@ -0,0 +1,51 @@ +defmodule Accent.ErrorView do + use Phoenix.View, root: "lib/web/templates" + + def render("400.json", %{reason: reason}) do + message = + case reason do + %Ecto.Query.CastError{} -> "Bad argument type cast" + _ -> "-" + end + + %{ + error: "Bad request", + message: message + } + end + + def render("404.json", %{reason: reason}) do + message = + case reason do + %Phoenix.Router.NoRouteError{} -> "Route not found" + %Ecto.NoResultsError{} -> "Resource not found" + _ -> "-" + end + + %{ + error: "Not found", + message: message + } + end + + def render("500.json", _assigns) do + %{ + error: "Internal error", + message: "An error occured, someone as been notified" + } + end + + def render("404.html", _assigns) do + "Page not found" + end + + def render("500.html", _assigns) do + "Server internal error" + end + + # In case no render clause matches or no + # template is found, let's render it as 500 + def template_not_found(_template, assigns) do + render("500.html", assigns) + end +end diff --git a/lib/web/views/peek_view.ex b/lib/web/views/peek_view.ex new file mode 100644 index 00000000..757fc750 --- /dev/null +++ b/lib/web/views/peek_view.ex @@ -0,0 +1,39 @@ +defmodule Accent.PeekView do + use Phoenix.View, root: "lib/web/templates" + + def render("index.json", %{operations: operations}) do + data = + Enum.reduce(operations, %{}, fn {revision_id, operations}, acc -> + acc + |> Map.put(revision_id, render_many(operations, Accent.PeekView, "operation.json")) + end) + + stats = + operations + |> Enum.reduce(%{}, fn {revision_id, operations}, acc -> + stat = fetch_stats(operations) + + acc + |> Map.put(revision_id, stat) + end) + + %{data: %{operations: data, stats: stats}} + end + + def render("operation.json", %{peek: operation}) do + %{ + text: operation.text, + key: operation.key, + action: operation.action, + "previous-text": operation.previous_translation["corrected_text"] || operation.previous_translation["proposed_text"] + } + end + + defp fetch_stats(operations) do + operations + |> Enum.group_by(&Map.get(&1, :action)) + |> Enum.reduce(%{}, fn {action, operations}, acc -> + Map.put(acc, action, Enum.count(operations)) + end) + end +end diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..1fd64e79 --- /dev/null +++ b/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mix.exs b/mix.exs new file mode 100644 index 00000000..38bff453 --- /dev/null +++ b/mix.exs @@ -0,0 +1,100 @@ +defmodule Accent.Mixfile do + use Mix.Project + + def project do + [ + app: :accent, + version: "0.0.1", + elixir: "~> 1.6", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix] ++ Mix.compilers(), + build_embedded: Mix.env() == :prod, + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + docs: [extras: ["API_DOC.md"]], + test_coverage: [tool: ExCoveralls] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [mod: {Accent, []}, extra_applications: [:logger, :canada]] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "web", "test/support"] + defp elixirc_paths(_), do: ["lib", "web"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + # Framework + {:phoenix, "~> 1.3"}, + {:phoenix_html, "~> 2.10"}, + {:postgrex, "~> 0.13"}, + + # Plugs + {:plug_assign, "~> 1.0.0"}, + {:plug, "~> 1.4", override: true}, + {:canary, "~> 1.1.0"}, + {:corsica, "~> 1.0"}, + + # Phoenix data helpers + {:ecto, "~> 2.2", override: true}, + {:phoenix_ecto, "~> 3.2", override: true}, + {:scrivener_ecto, "~> 1.0"}, + {:dataloader, "~> 1.0"}, + + # GraphQL + {:absinthe, "~> 1.4"}, + {:absinthe_plug, "~> 1.4"}, + + # Utils + {:p1_utils, github: "processone/p1_utils", override: true}, + {:fast_yaml, "~> 1.0.0"}, + {:jiffy, github: "davisp/jiffy"}, + {:mochiweb_html, "~> 2.13"}, + {:httpoison, "~> 1.1.0"}, + {:gettext, "~> 0.11"}, + + # Errors + {:sentry, "~> 6.0"}, + + # Mails + {:bamboo, "~> 0.8"}, + {:bamboo_smtp, "~> 1.4.0"}, + + # Events handling + {:gen_stage, "~> 0.11"}, + + # Mock testing + {:mox, "~> 0.3"}, + {:mock, "~> 0.3.0", only: :test}, + + # Dev + {:dialyxir, "~> 0.5", only: ~w(dev test)a, runtime: false}, + {:credo, ">= 0.0.0", only: ~w(dev test)a}, + {:excoveralls, "~> 0.8", only: :test}, + {:phoenix_live_reload, "~> 1.0", only: :dev} + ] + end + + # Aliases are shortcut or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 00000000..79c6c5d3 --- /dev/null +++ b/mix.lock @@ -0,0 +1,65 @@ +%{ + "absinthe": {:hex, :absinthe, "1.4.10", "9f8d0c34dfcfd0030d3a3f123c7501e99ab59651731387289dad5885047ebb2a", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "absinthe_plug": {:hex, :absinthe_plug, "1.4.2", "01bf16f0a637869bcc0a1919935f08ff853501004e7549ddaa3a7788deb48965", [:mix], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "backoff": {:hex, :backoff, "1.1.1"}, + "bamboo": {:hex, :bamboo, "0.8.0", "573889a3efcb906bb9d25a1c4caa4ca22f479235e1b8cc3260d8b88dabeb4b14", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "bamboo_smtp": {:hex, :bamboo_smtp, "1.4.0", "a01d91406f3a46b3452c84d345d50f75d6facca5e06337358287a97da0426240", [:mix], [{:bamboo, "~> 0.8.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.12.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, + "canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm"}, + "canary": {:hex, :canary, "1.1.1", "4138d5e05db8497c477e4af73902eb9ae06e49dceaa13c2dd9f0b55525ded48b", [:mix], [{:canada, "~> 1.0.1", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, + "cors_plug": {:hex, :cors_plug, "1.1.0"}, + "corsica": {:hex, :corsica, "1.1.1", "8fbcccb5006decb6aeb35132d3a3f9e27dce70f6e021c53875392f408832c16f", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, + "credo": {:hex, :credo, "0.9.0", "5d1b494e4f2dc672b8318e027bd833dda69be71eaac6eedd994678be74ef7cb4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "dataloader": {:hex, :dataloader, "1.0.1", "7a4328683e3ab8608d1b77a3beb575defb0a70bdbb51d80890be3a90633a624e", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], [], "hexpm"}, + "ecto": {:hex, :ecto, "2.2.9", "031d55df9bb430cb118e6f3026a87408d9ce9638737bda3871e5d727a3594aae", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.12.0", "b774aabfede4af31c0301aece12371cbd25995a21bb3d71d66f5c2fe074c603f", [:mix], [{:earmark, "~> 0.2", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.8.1", "0bbf67f22c7dbf7503981d21a5eef5db8bbc3cb86e70d3798e8c802c74fa5e27", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, + "fast_yaml": {:hex, :fast_yaml, "1.0.13", "adcb8db20bb96d4e56b63b48c75d47ca15a6bd409da0200ffbd32db382131e22", [:rebar3], [{:p1_utils, "1.0.11", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm"}, + "file_system": {:hex, :file_system, "0.2.4", "f0bdda195c0e46e987333e986452ec523aed21d784189144f647c43eaf307064", [:mix], [], "hexpm"}, + "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], [], "hexpm"}, + "gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"}, + "gen_stage": {:hex, :gen_stage, "0.13.1", "edff5bca9cab22c5d03a834062515e6a1aeeb7665fb44eddae086252e39c4378", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.11.0", "4951ee019df102492dabba66a09e305f61919a8a183a7860236c0fde586134b6", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.1.0", "497949fb62924432f64a45269d20e6f61ecf35084ffa270917afcdb7cd4d8061", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "inflex": {:hex, :inflex, "1.5.0", "e4ff5d900280b2011b24d1ac1c4590986ee5add2ea644c9894e72213cf93ff0b", [:mix], []}, + "ja_serializer": {:git, "https://github.com/AgilionApps/ja_serializer.git", "bbbf6753a25ca9e7df7129d1142b5ddda0c58916", []}, + "jiffy": {:git, "https://github.com/davisp/jiffy.git", "fe43b59f4f5e8aafb111f400a7e4f813cd134b55", []}, + "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, + "meck": {:hex, :meck, "0.8.9", "64c5c0bd8bcca3a180b44196265c8ed7594e16bcc845d0698ec6b4e577f48188", [:rebar3], [], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "mochiweb_html": {:hex, :mochiweb_html, "2.15.0", "d7402e967d7f9f2912f8befa813c37be62d5eeeddbbcb6fe986c44e01460d497", [:rebar3], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, + "mox": {:hex, :mox, "0.3.2", "3b9b8364fd4f28628139de701d97c636b27a8f925f57a8d5a1b85fbd620dad3a", [:mix], [], "hexpm"}, + "p1_utils": {:git, "https://github.com/processone/p1_utils.git", "8dc0ae64af141f7cefe7a78585d7733e5e5c9f5a", []}, + "phoenix": {:hex, :phoenix, "1.3.2", "2a00d751f51670ea6bc3f2ba4e6eb27ecb8a2c71e7978d9cd3e5de5ccf7378bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_html": {:hex, :phoenix_html, "2.11.1", "77b6f7fbd252168c6ec4f573de648d37cc5258cda13266ef001fbf99267eb6f3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.3", "1d178429fc8950b12457d09c6afec247bfe1fcb6f36209e18fbb0221bdfe4d41", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.5.0", "224b25b4039bedc1eac149fb52ed456770b9678bbf0349cdd810460e1e09195b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_assign": {:hex, :plug_assign, "1.0.0", "368688e6acd30796237d0a2f25cd30391d28932bfcbaa7f15d7a6549c661d64b", [:mix], [{:plug, "~> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, + "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, + "postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, + "scrivener": {:hex, :scrivener, "2.5.0", "e1f78c62b6806d91cc9c4778deef1ea4e80aa9fadfce2c16831afe0468cc8a2c", [:mix], [], "hexpm"}, + "scrivener_ecto": {:hex, :scrivener_ecto, "1.3.0", "69698428e22810ac8a47abc12d1df5b2f5d8f6b36dc5d5bfe6dd93fde857c576", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.11.0 or ~> 0.12.0 or ~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm"}, + "sentry": {:hex, :sentry, "6.1.0", "ee3d5cdc2294b6235159e03562c6a089807142164dcffdb769af08b42285eb37", [:mix], [{:hackney, "~> 1.8 or 1.6.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}, {:uuid, "~> 1.0", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, + "solage": {:git, "https://github.com/simonprev/solage.git", "e17ad3eed0cd48d355b92f750fddd6cce8d1d959", []}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, + "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, +} diff --git a/phoenix_static_buildpack.config b/phoenix_static_buildpack.config new file mode 100644 index 00000000..af104601 --- /dev/null +++ b/phoenix_static_buildpack.config @@ -0,0 +1,2 @@ +node_version=9.5.0 +assets_path=webapp diff --git a/priv/repo/languages.json b/priv/repo/languages.json new file mode 100644 index 00000000..1bc10e3e --- /dev/null +++ b/priv/repo/languages.json @@ -0,0 +1,3132 @@ +[ + { + "name": "Acholi", + "slug": "ach", + "iso_639_1": "ach", + "iso_639_3": "ach", + "locale": "ach-UG", + "android_code": "ach-rUG", + "osx_code": "ach.lproj", + "osx_locale": "ach" + }, + { + "name": "Afar", + "slug": "aa", + "iso_639_1": "aa", + "iso_639_3": "aar", + "locale": "aa-ER", + "android_code": "aa-rER", + "osx_code": "aa.lproj", + "osx_locale": "aa" + }, + { + "name": "Afrikaans", + "slug": "af", + "iso_639_1": "af", + "iso_639_3": "afr", + "locale": "af-ZA", + "android_code": "af-rZA", + "osx_code": "af.lproj", + "osx_locale": "af" + }, + { + "name": "Akan", + "slug": "ak", + "iso_639_1": "ak", + "iso_639_3": "aka", + "locale": "ak-GH", + "android_code": "ak-rGH", + "osx_code": "ak.lproj", + "osx_locale": "ak" + }, + { + "name": "Akan, Twi", + "slug": "tw", + "iso_639_1": "tw", + "iso_639_3": "twi", + "locale": "tw-TW", + "android_code": "tw-rTW", + "osx_code": "tw.lproj", + "osx_locale": "tw" + }, + { + "name": "Albanian", + "slug": "sq", + "iso_639_1": "sq", + "iso_639_3": "sqi", + "locale": "sq-AL", + "android_code": "sq-rAL", + "osx_code": "sq.lproj", + "osx_locale": "sq" + }, + { + "name": "Amharic", + "slug": "am", + "iso_639_1": "am", + "iso_639_3": "amh", + "locale": "am-ET", + "android_code": "am-rET", + "osx_code": "am.lproj", + "osx_locale": "am" + }, + { + "name": "Arabic", + "slug": "ar", + "iso_639_1": "ar", + "iso_639_3": "ara", + "locale": "ar-SA", + "android_code": "ar-rSA", + "osx_code": "ar.lproj", + "osx_locale": "ar" + }, + { + "name": "Arabic, Bahrain", + "slug": "ar-BH", + "iso_639_1": "ar", + "iso_639_3": "ara", + "locale": "ar-BH", + "android_code": "ar-rBH", + "osx_code": "ar-BH.lproj", + "osx_locale": "ar_BH" + }, + { + "name": "Arabic, Egypt", + "slug": "ar-EG", + "iso_639_1": "ar", + "iso_639_3": "ara", + "locale": "ar-EG", + "android_code": "ar-rEG", + "osx_code": "ar-EG.lproj", + "osx_locale": "ar_EG" + }, + { + "name": "Arabic, Saudi Arabia", + "slug": "ar-SA", + "iso_639_1": "ar", + "iso_639_3": "ara", + "locale": "ar-SA", + "android_code": "ar-rSA", + "osx_code": "ar-SA.lproj", + "osx_locale": "ar_SA" + }, + { + "name": "Arabic, Yemen", + "slug": "ar-YE", + "iso_639_1": "ar", + "iso_639_3": "ara", + "locale": "ar-YE", + "android_code": "ar-rYE", + "osx_code": "ar-YE.lproj", + "osx_locale": "ar_YE" + }, + { + "name": "Aragonese", + "slug": "an", + "iso_639_1": "an", + "iso_639_3": "arg", + "locale": "an-ES", + "android_code": "an-rES", + "osx_code": "an.lproj", + "osx_locale": "an" + }, + { + "name": "Armenian", + "slug": "hy-AM", + "iso_639_1": "hy", + "iso_639_3": "hye", + "locale": "hy-AM", + "android_code": "hy-rAM", + "osx_code": "hy.lproj", + "osx_locale": "hy" + }, + { + "name": "Arpitan", + "slug": "frp", + "iso_639_1": "frp", + "iso_639_3": "frp", + "locale": "frp-IT", + "android_code": "frp-rIT", + "osx_code": "frp.lproj", + "osx_locale": "frp" + }, + { + "name": "Assamese", + "slug": "as", + "iso_639_1": "as", + "iso_639_3": "asm", + "locale": "as-IN", + "android_code": "as-rIN", + "osx_code": "as.lproj", + "osx_locale": "as" + }, + { + "name": "Asturian", + "slug": "ast", + "iso_639_1": "ast", + "iso_639_3": "ast", + "locale": "ast-ES", + "android_code": "ast-rES", + "osx_code": "ast.lproj", + "osx_locale": "ast" + }, + { + "name": "Atayal", + "slug": "tay", + "iso_639_1": "tay", + "iso_639_3": "tay", + "locale": "tay-TW", + "android_code": "tay-rTW", + "osx_code": "tay.lproj", + "osx_locale": "tay" + }, + { + "name": "Avaric", + "slug": "av", + "iso_639_1": "av", + "iso_639_3": "ava", + "locale": "av-DA", + "android_code": "av-rDA", + "osx_code": "av.lproj", + "osx_locale": "av" + }, + { + "name": "Avestan", + "slug": "ae", + "iso_639_1": "ae", + "iso_639_3": "ave", + "locale": "ae-IR", + "android_code": "ae-rIR", + "osx_code": "ae.lproj", + "osx_locale": "ae" + }, + { + "name": "Aymara", + "slug": "ay", + "iso_639_1": "ay", + "iso_639_3": "aym", + "locale": "ay-BO", + "android_code": "ay-rBO", + "osx_code": "ay.lproj", + "osx_locale": "ay" + }, + { + "name": "Azerbaijani", + "slug": "az", + "iso_639_1": "az", + "iso_639_3": "aze", + "locale": "az-AZ", + "android_code": "az-rAZ", + "osx_code": "az.lproj", + "osx_locale": "az" + }, + { + "name": "Balinese", + "slug": "ban", + "iso_639_1": "ban", + "iso_639_3": "ban", + "locale": "ban-ID", + "android_code": "ban-rID", + "osx_code": "ban.lproj", + "osx_locale": "ban" + }, + { + "name": "Balochi", + "slug": "bal", + "iso_639_1": "bal", + "iso_639_3": "bal", + "locale": "bal-BA", + "android_code": "bal-rBA", + "osx_code": "bal.lproj", + "osx_locale": "bal" + }, + { + "name": "Bambara", + "slug": "bm", + "iso_639_1": "bm", + "iso_639_3": "bam", + "locale": "bm-ML", + "android_code": "bm-rML", + "osx_code": "bm.lproj", + "osx_locale": "bm" + }, + { + "name": "Bashkir", + "slug": "ba", + "iso_639_1": "ba", + "iso_639_3": "bak", + "locale": "ba-RU", + "android_code": "ba-rRU", + "osx_code": "ba.lproj", + "osx_locale": "ba" + }, + { + "name": "Basque", + "slug": "eu", + "iso_639_1": "eu", + "iso_639_3": "eus", + "locale": "eu-ES", + "android_code": "eu-rES", + "osx_code": "eu.lproj", + "osx_locale": "eu" + }, + { + "name": "Belarusian", + "slug": "be", + "iso_639_1": "be", + "iso_639_3": "bel", + "locale": "be-BY", + "android_code": "be-rBY", + "osx_code": "be.lproj", + "osx_locale": "be" + }, + { + "name": "Bengali", + "slug": "bn", + "iso_639_1": "bn", + "iso_639_3": "ben", + "locale": "bn-BD", + "android_code": "bn-rBD", + "osx_code": "bn.lproj", + "osx_locale": "bn" + }, + { + "name": "Bengali, India", + "slug": "bn-IN", + "iso_639_1": "bn", + "iso_639_3": "ben", + "locale": "bn-IN", + "android_code": "bn-rIN", + "osx_code": "bn-IN.lproj", + "osx_locale": "bn_IN" + }, + { + "name": "Berber", + "slug": "ber", + "iso_639_1": "ber", + "iso_639_3": "ber", + "locale": "ber-DZ", + "android_code": "ber-rDZ", + "osx_code": "ber.lproj", + "osx_locale": "ber" + }, + { + "name": "Bihari", + "slug": "bh", + "iso_639_1": "bh", + "iso_639_3": "bih", + "locale": "bh-IN", + "android_code": "bh-rIN", + "osx_code": "bh.lproj", + "osx_locale": "bh" + }, + { + "name": "Birifor", + "slug": "bfo", + "iso_639_1": "bfo", + "iso_639_3": "bfo", + "locale": "bfo-BF", + "android_code": "bfo-rBF", + "osx_code": "bfo.lproj", + "osx_locale": "bfo" + }, + { + "name": "Bislama", + "slug": "bi", + "iso_639_1": "bi", + "iso_639_3": "bis", + "locale": "bi-VU", + "android_code": "bi-rVU", + "osx_code": "bi.lproj", + "osx_locale": "bi" + }, + { + "name": "Bosnian", + "slug": "bs", + "iso_639_1": "bs", + "iso_639_3": "bos", + "locale": "bs-BA", + "android_code": "bs-rBA", + "osx_code": "bs.lproj", + "osx_locale": "bs" + }, + { + "name": "Breton", + "slug": "br-FR", + "iso_639_1": "br", + "iso_639_3": "bre", + "locale": "br-FR", + "android_code": "br-rFR", + "osx_code": "br.lproj", + "osx_locale": "br" + }, + { + "name": "Bulgarian", + "slug": "bg", + "iso_639_1": "bg", + "iso_639_3": "bul", + "locale": "bg-BG", + "android_code": "bg-rBG", + "osx_code": "bg.lproj", + "osx_locale": "bg" + }, + { + "name": "Burmese", + "slug": "my", + "iso_639_1": "my", + "iso_639_3": "mya", + "locale": "my-MM", + "android_code": "my-rMM", + "osx_code": "my.lproj", + "osx_locale": "my" + }, + { + "name": "Catalan", + "slug": "ca", + "iso_639_1": "ca", + "iso_639_3": "cat", + "locale": "ca-ES", + "android_code": "ca-rES", + "osx_code": "ca.lproj", + "osx_locale": "ca" + }, + { + "name": "Cebuano", + "slug": "ceb", + "iso_639_1": "ceb", + "iso_639_3": "ceb", + "locale": "ceb-PH", + "android_code": "ceb-rPH", + "osx_code": "ceb.lproj", + "osx_locale": "ceb" + }, + { + "name": "Chamorro", + "slug": "ch", + "iso_639_1": "ch", + "iso_639_3": "cha", + "locale": "ch-GU", + "android_code": "ch-rGU", + "osx_code": "ch.lproj", + "osx_locale": "ch" + }, + { + "name": "Chechen", + "slug": "ce", + "iso_639_1": "ce", + "iso_639_3": "che", + "locale": "ce-CE", + "android_code": "ce-rCE", + "osx_code": "ce.lproj", + "osx_locale": "ce" + }, + { + "name": "Cherokee", + "slug": "chr", + "iso_639_1": "chr", + "iso_639_3": "chr", + "locale": "chr-US", + "android_code": "chr-rUS", + "osx_code": "chr.lproj", + "osx_locale": "chr" + }, + { + "name": "Chewa", + "slug": "ny", + "iso_639_1": "ny", + "iso_639_3": "nya", + "locale": "ny-MW", + "android_code": "ny-rMW", + "osx_code": "ny.lproj", + "osx_locale": "ny" + }, + { + "name": "Chinese Simplified", + "slug": "zh-CN", + "iso_639_1": "zh", + "iso_639_3": "zho", + "locale": "zh-CN", + "android_code": "zh-rCN", + "osx_code": "zh-Hans.lproj", + "osx_locale": "zh-Hans" + }, + { + "name": "Chinese Traditional", + "slug": "zh-TW", + "iso_639_1": "zh", + "iso_639_3": "zho", + "locale": "zh-TW", + "android_code": "zh-rTW", + "osx_code": "zh-Hant.lproj", + "osx_locale": "zh-Hant" + }, + { + "name": "Chinese Traditional, Hong Kong", + "slug": "zh-HK", + "iso_639_1": "zh", + "iso_639_3": "zho", + "locale": "zh-HK", + "android_code": "zh-rHK", + "osx_code": "zh-HK.lproj", + "osx_locale": "zh_HK" + }, + { + "name": "Chinese Traditional, Macau", + "slug": "zh-MO", + "iso_639_1": "zh", + "iso_639_3": "zho", + "locale": "zh-MO", + "android_code": "zh-rMO", + "osx_code": "zh-MO.lproj", + "osx_locale": "zh_MO" + }, + { + "name": "Chinese Traditional, Singapore", + "slug": "zh-SG", + "iso_639_1": "zh", + "iso_639_3": "zho", + "locale": "zh-SG", + "android_code": "zh-rSG", + "osx_code": "zh-SG.lproj", + "osx_locale": "zh_SG" + }, + { + "name": "Chuvash", + "slug": "cv", + "iso_639_1": "cv", + "iso_639_3": "chv", + "locale": "cv-CU", + "android_code": "cv-rCU", + "osx_code": "cv.lproj", + "osx_locale": "cv" + }, + { + "name": "Cornish", + "slug": "kw", + "iso_639_1": "kw", + "iso_639_3": "cor", + "locale": "kw-GB", + "android_code": "kw-rGB", + "osx_code": "kw.lproj", + "osx_locale": "kw" + }, + { + "name": "Corsican", + "slug": "co", + "iso_639_1": "co", + "iso_639_3": "cos", + "locale": "co-FR", + "android_code": "co-rFR", + "osx_code": "co.lproj", + "osx_locale": "co" + }, + { + "name": "Cree", + "slug": "cr", + "iso_639_1": "cr", + "iso_639_3": "cre", + "locale": "cr-NT", + "android_code": "cr-rNT", + "osx_code": "cr.lproj", + "osx_locale": "cr" + }, + { + "name": "Croatian", + "slug": "hr", + "iso_639_1": "hr", + "iso_639_3": "hrv", + "locale": "hr-HR", + "android_code": "hr-rHR", + "osx_code": "hr.lproj", + "osx_locale": "hr" + }, + { + "name": "Czech", + "slug": "cs", + "iso_639_1": "cs", + "iso_639_3": "ces", + "locale": "cs-CZ", + "android_code": "cs-rCZ", + "osx_code": "cs.lproj", + "osx_locale": "cs" + }, + { + "name": "Danish", + "slug": "da", + "iso_639_1": "da", + "iso_639_3": "dan", + "locale": "da-DK", + "android_code": "da-rDK", + "osx_code": "da.lproj", + "osx_locale": "da" + }, + { + "name": "Dari", + "slug": "fa-AF", + "iso_639_1": "fa", + "iso_639_3": "prs", + "locale": "fa-AF", + "android_code": "fa-rAF", + "osx_code": "fa-AF.lproj", + "osx_locale": "fa_AF" + }, + { + "name": "Dhivehi", + "slug": "dv", + "iso_639_1": "dv", + "iso_639_3": "div", + "locale": "dv-MV", + "android_code": "dv-rMV", + "osx_code": "dv.lproj", + "osx_locale": "dv" + }, + { + "name": "Dutch", + "slug": "nl", + "iso_639_1": "nl", + "iso_639_3": "nld", + "locale": "nl-NL", + "android_code": "nl-rNL", + "osx_code": "nl.lproj", + "osx_locale": "nl" + }, + { + "name": "Dutch, Belgium", + "slug": "nl-BE", + "iso_639_1": "nl", + "iso_639_3": "nld", + "locale": "nl-BE", + "android_code": "nl-rBE", + "osx_code": "nl-BE.lproj", + "osx_locale": "nl_BE" + }, + { + "name": "Dutch, Suriname", + "slug": "nl-SR", + "iso_639_1": "nl", + "iso_639_3": "nld", + "locale": "nl-SR", + "android_code": "nl-rSR", + "osx_code": "nl-SR.lproj", + "osx_locale": "nl_SR" + }, + { + "name": "Dzongkha", + "slug": "dz", + "iso_639_1": "dz", + "iso_639_3": "dzo", + "locale": "dz-BT", + "android_code": "dz-rBT", + "osx_code": "dz.lproj", + "osx_locale": "dz" + }, + { + "name": "English", + "slug": "en", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-US", + "android_code": "en-rUS", + "osx_code": "en.lproj", + "osx_locale": "en" + }, + { + "name": "English (upside down)", + "slug": "en-UD", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-UD", + "android_code": "en-rUD", + "osx_code": "en-UD.lproj", + "osx_locale": "en_UD" + }, + { + "name": "English, Arabia", + "slug": "en-AR", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-AR", + "android_code": "en-rAR", + "osx_code": "en-AR.lproj", + "osx_locale": "en_AR" + }, + { + "name": "English, Australia", + "slug": "en-AU", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-AU", + "android_code": "en-rAU", + "osx_code": "en-AU.lproj", + "osx_locale": "en_AU" + }, + { + "name": "English, Belize", + "slug": "en-BZ", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-BZ", + "android_code": "en-rBZ", + "osx_code": "en-BZ.lproj", + "osx_locale": "en_BZ" + }, + { + "name": "English, Canada", + "slug": "en-CA", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-CA", + "android_code": "en-rCA", + "osx_code": "en-CA.lproj", + "osx_locale": "en_CA" + }, + { + "name": "English, Caribbean", + "slug": "en-CB", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-CB", + "android_code": "en-rCB", + "osx_code": "en-CB.lproj", + "osx_locale": "en_CB" + }, + { + "name": "English, China", + "slug": "en-CN", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-CN", + "android_code": "en-rCN", + "osx_code": "en-CN.lproj", + "osx_locale": "en_CN" + }, + { + "name": "English, Denmark", + "slug": "en-DK", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-DK", + "android_code": "en-rDK", + "osx_code": "en-DK.lproj", + "osx_locale": "en_DK" + }, + { + "name": "English, Hong Kong", + "slug": "en-HK", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-HK", + "android_code": "en-rHK", + "osx_code": "en-HK.lproj", + "osx_locale": "en_HK" + }, + { + "name": "English, India", + "slug": "en-IN", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-IN", + "android_code": "en-rIN", + "osx_code": "en-IN.lproj", + "osx_locale": "en_IN" + }, + { + "name": "English, Indonesia", + "slug": "en-ID", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-ID", + "android_code": "en-rID", + "osx_code": "en-ID.lproj", + "osx_locale": "en_ID" + }, + { + "name": "English, Ireland", + "slug": "en-IE", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-IE", + "android_code": "en-rIE", + "osx_code": "en-IE.lproj", + "osx_locale": "en_IE" + }, + { + "name": "English, Jamaica", + "slug": "en-JM", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-JM", + "android_code": "en-rJM", + "osx_code": "en-JM.lproj", + "osx_locale": "en_JM" + }, + { + "name": "English, Japan", + "slug": "en-JA", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-JA", + "android_code": "en-rJA", + "osx_code": "en-JA.lproj", + "osx_locale": "en_JA" + }, + { + "name": "English, Malaysia", + "slug": "en-MY", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-MY", + "android_code": "en-rMY", + "osx_code": "en-MY.lproj", + "osx_locale": "en_MY" + }, + { + "name": "English, New Zealand", + "slug": "en-NZ", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-NZ", + "android_code": "en-rNZ", + "osx_code": "en-NZ.lproj", + "osx_locale": "en_NZ" + }, + { + "name": "English, Norway", + "slug": "en-NO", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-NO", + "android_code": "en-rNO", + "osx_code": "en-NO.lproj", + "osx_locale": "en_NO" + }, + { + "name": "English, Philippines", + "slug": "en-PH", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-PH", + "android_code": "en-rPH", + "osx_code": "en-PH.lproj", + "osx_locale": "en_PH" + }, + { + "name": "English, Puerto Rico", + "slug": "en-PR", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-PR", + "android_code": "en-rPR", + "osx_code": "en-PR.lproj", + "osx_locale": "en_PR" + }, + { + "name": "English, Singapore", + "slug": "en-SG", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-SG", + "android_code": "en-rSG", + "osx_code": "en-SG.lproj", + "osx_locale": "en_SG" + }, + { + "name": "English, South Africa", + "slug": "en-ZA", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-ZA", + "android_code": "en-rZA", + "osx_code": "en-ZA.lproj", + "osx_locale": "en_ZA" + }, + { + "name": "English, Sweden", + "slug": "en-SE", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-SE", + "android_code": "en-rSE", + "osx_code": "en-SE.lproj", + "osx_locale": "en_SE" + }, + { + "name": "English, United Kingdom", + "slug": "en-GB", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-GB", + "android_code": "en-rGB", + "osx_code": "en-GB.lproj", + "osx_locale": "en_GB" + }, + { + "name": "English, United States", + "slug": "en-US", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-US", + "android_code": "en-rUS", + "osx_code": "en-US.lproj", + "osx_locale": "en_US" + }, + { + "name": "English, Zimbabwe", + "slug": "en-ZW", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-ZW", + "android_code": "en-rZW", + "osx_code": "en-ZW.lproj", + "osx_locale": "en_ZW" + }, + { + "name": "Esperanto", + "slug": "eo", + "iso_639_1": "eo", + "iso_639_3": "epo", + "locale": "eo-UY", + "android_code": "eo-rUY", + "osx_code": "eo.lproj", + "osx_locale": "eo" + }, + { + "name": "Estonian", + "slug": "et", + "iso_639_1": "et", + "iso_639_3": "est", + "locale": "et-EE", + "android_code": "et-rEE", + "osx_code": "et.lproj", + "osx_locale": "et" + }, + { + "name": "Ewe", + "slug": "ee", + "iso_639_1": "ee", + "iso_639_3": "ewe", + "locale": "ee-GH", + "android_code": "ee-rGH", + "osx_code": "ee.lproj", + "osx_locale": "ee" + }, + { + "name": "Faroese", + "slug": "fo", + "iso_639_1": "fo", + "iso_639_3": "fao", + "locale": "fo-FO", + "android_code": "fo-rFO", + "osx_code": "fo.lproj", + "osx_locale": "fo" + }, + { + "name": "Fijian", + "slug": "fj", + "iso_639_1": "fj", + "iso_639_3": "fij", + "locale": "fj-FJ", + "android_code": "fj-rFJ", + "osx_code": "fj.lproj", + "osx_locale": "fj" + }, + { + "name": "Filipino", + "slug": "fil", + "iso_639_1": "fil", + "iso_639_3": "fil", + "locale": "fil-PH", + "android_code": "fil-rPH", + "osx_code": "fil.lproj", + "osx_locale": "fil" + }, + { + "name": "Finnish", + "slug": "fi", + "iso_639_1": "fi", + "iso_639_3": "fin", + "locale": "fi-FI", + "android_code": "fi-rFI", + "osx_code": "fi.lproj", + "osx_locale": "fi" + }, + { + "name": "Flemish", + "slug": "vls-BE", + "iso_639_1": "vls", + "iso_639_3": "vls", + "locale": "vls-BE", + "android_code": "vls-rBE", + "osx_code": "vls.lproj", + "osx_locale": "vls" + }, + { + "name": "Franconian", + "slug": "fra-DE", + "iso_639_1": "fra", + "iso_639_3": "gem", + "locale": "fra-DE", + "android_code": "fra-rDE", + "osx_code": "fra.lproj", + "osx_locale": "fra" + }, + { + "name": "French", + "slug": "fr", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-FR", + "android_code": "fr-rFR", + "osx_code": "fr.lproj", + "osx_locale": "fr" + }, + { + "name": "French, Belgium", + "slug": "fr-BE", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-BE", + "android_code": "fr-rBE", + "osx_code": "fr-BE.lproj", + "osx_locale": "fr_BE" + }, + { + "name": "French, Canada", + "slug": "fr-CA", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-CA", + "android_code": "fr-rCA", + "osx_code": "fr-CA.lproj", + "osx_locale": "fr_CA" + }, + { + "name": "French, Luxembourg", + "slug": "fr-LU", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-LU", + "android_code": "fr-rLU", + "osx_code": "fr-LU.lproj", + "osx_locale": "fr_LU" + }, + { + "name": "French, Quebec", + "slug": "fr-QC", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-QC", + "android_code": "fr-rQC", + "osx_code": "fr-QC.lproj", + "osx_locale": "fr_QC" + }, + { + "name": "French, Switzerland", + "slug": "fr-CH", + "iso_639_1": "fr", + "iso_639_3": "fra", + "locale": "fr-CH", + "android_code": "fr-rCH", + "osx_code": "fr-CH.lproj", + "osx_locale": "fr_CH" + }, + { + "name": "Frisian", + "slug": "fy-NL", + "iso_639_1": "fy", + "iso_639_3": "fry", + "locale": "fy-NL", + "android_code": "fy-rNL", + "osx_code": "fy.lproj", + "osx_locale": "fy" + }, + { + "name": "Friulian", + "slug": "fur-IT", + "iso_639_1": "fur", + "iso_639_3": "fur", + "locale": "fur-IT", + "android_code": "fur-rIT", + "osx_code": "fur.lproj", + "osx_locale": "fur" + }, + { + "name": "Fula", + "slug": "ff", + "iso_639_1": "ff", + "iso_639_3": "ful", + "locale": "ff-ZA", + "android_code": "ff-rZA", + "osx_code": "ff.lproj", + "osx_locale": "ff" + }, + { + "name": "Ga", + "slug": "gaa", + "iso_639_1": "gaa", + "iso_639_3": "gaa", + "locale": "gaa-GH", + "android_code": "gaa-rGH", + "osx_code": "gaa.lproj", + "osx_locale": "gaa" + }, + { + "name": "Galician", + "slug": "gl", + "iso_639_1": "gl", + "iso_639_3": "glg", + "locale": "gl-ES", + "android_code": "gl-rES", + "osx_code": "gl.lproj", + "osx_locale": "gl" + }, + { + "name": "Georgian", + "slug": "ka", + "iso_639_1": "ka", + "iso_639_3": "kat", + "locale": "ka-GE", + "android_code": "ka-rGE", + "osx_code": "ka.lproj", + "osx_locale": "ka" + }, + { + "name": "German", + "slug": "de", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-DE", + "android_code": "de-rDE", + "osx_code": "de.lproj", + "osx_locale": "de" + }, + { + "name": "German, Austria", + "slug": "de-AT", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-AT", + "android_code": "de-rAT", + "osx_code": "de-AT.lproj", + "osx_locale": "de_AT" + }, + { + "name": "German, Belgium", + "slug": "de-BE", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-BE", + "android_code": "de-rBE", + "osx_code": "de-BE.lproj", + "osx_locale": "de_BE" + }, + { + "name": "German, Liechtenstein", + "slug": "de-LI", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-LI", + "android_code": "de-rLI", + "osx_code": "de-LI.lproj", + "osx_locale": "de_LI" + }, + { + "name": "German, Luxembourg", + "slug": "de-LU", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-LU", + "android_code": "de-rLU", + "osx_code": "de-LU.lproj", + "osx_locale": "de_LU" + }, + { + "name": "German, Switzerland", + "slug": "de-CH", + "iso_639_1": "de", + "iso_639_3": "deu", + "locale": "de-CH", + "android_code": "de-rCH", + "osx_code": "de-CH.lproj", + "osx_locale": "de_CH" + }, + { + "name": "Gothic", + "slug": "got", + "iso_639_1": "got", + "iso_639_3": "got", + "locale": "got-DE", + "android_code": "got-rDE", + "osx_code": "got.lproj", + "osx_locale": "got" + }, + { + "name": "Greek", + "slug": "el", + "iso_639_1": "el", + "iso_639_3": "ell", + "locale": "el-GR", + "android_code": "el-rGR", + "osx_code": "el.lproj", + "osx_locale": "el" + }, + { + "name": "Greek, Cyprus", + "slug": "el-CY", + "iso_639_1": "el", + "iso_639_3": "ell", + "locale": "el-CY", + "android_code": "el-rCY", + "osx_code": "el-CY.lproj", + "osx_locale": "el_CY" + }, + { + "name": "Greenlandic", + "slug": "kl", + "iso_639_1": "kl", + "iso_639_3": "kal", + "locale": "kl-GL", + "android_code": "kl-rGL", + "osx_code": "kl.lproj", + "osx_locale": "kl" + }, + { + "name": "Guarani", + "slug": "gn", + "iso_639_1": "gn", + "iso_639_3": "grn", + "locale": "gn-PY", + "android_code": "gn-rPY", + "osx_code": "gn.lproj", + "osx_locale": "gn" + }, + { + "name": "Gujarati", + "slug": "gu-IN", + "iso_639_1": "gu", + "iso_639_3": "guj", + "locale": "gu-IN", + "android_code": "gu-rIN", + "osx_code": "gu.lproj", + "osx_locale": "gu" + }, + { + "name": "Haitian Creole", + "slug": "ht", + "iso_639_1": "ht", + "iso_639_3": "hat", + "locale": "ht-HT", + "android_code": "ht-rHT", + "osx_code": "ht.lproj", + "osx_locale": "ht" + }, + { + "name": "Hausa", + "slug": "ha", + "iso_639_1": "ha", + "iso_639_3": "hau", + "locale": "ha-HG", + "android_code": "ha-rHG", + "osx_code": "ha.lproj", + "osx_locale": "ha" + }, + { + "name": "Hawaiian", + "slug": "haw", + "iso_639_1": "haw", + "iso_639_3": "haw", + "locale": "haw-US", + "android_code": "haw-rUS", + "osx_code": "haw.lproj", + "osx_locale": "haw" + }, + { + "name": "Hebrew", + "slug": "he", + "iso_639_1": "he", + "iso_639_3": "heb", + "locale": "he-IL", + "android_code": "iw-rIL", + "osx_code": "he.lproj", + "osx_locale": "he" + }, + { + "name": "Herero", + "slug": "hz", + "iso_639_1": "hz", + "iso_639_3": "her", + "locale": "hz-NA", + "android_code": "hz-rNA", + "osx_code": "hz.lproj", + "osx_locale": "hz" + }, + { + "name": "Hiligaynon", + "slug": "hil", + "iso_639_1": "hil", + "iso_639_3": "hil", + "locale": "hil-PH", + "android_code": "hil-rPH", + "osx_code": "hil.lproj", + "osx_locale": "hil" + }, + { + "name": "Hindi", + "slug": "hi", + "iso_639_1": "hi", + "iso_639_3": "hin", + "locale": "hi-IN", + "android_code": "hi-rIN", + "osx_code": "hi.lproj", + "osx_locale": "hi" + }, + { + "name": "Hiri Motu", + "slug": "ho", + "iso_639_1": "ho", + "iso_639_3": "hmo", + "locale": "ho-PG", + "android_code": "ho-rPG", + "osx_code": "ho.lproj", + "osx_locale": "ho" + }, + { + "name": "Hmong", + "slug": "hmn", + "iso_639_1": "hmn", + "iso_639_3": "hmn", + "locale": "hmn-CN", + "android_code": "hmn-rCN", + "osx_code": "hmn.lproj", + "osx_locale": "hmn" + }, + { + "name": "Hungarian", + "slug": "hu", + "iso_639_1": "hu", + "iso_639_3": "hun", + "locale": "hu-HU", + "android_code": "hu-rHU", + "osx_code": "hu.lproj", + "osx_locale": "hu" + }, + { + "name": "Icelandic", + "slug": "is", + "iso_639_1": "is", + "iso_639_3": "isl", + "locale": "is-IS", + "android_code": "is-rIS", + "osx_code": "is.lproj", + "osx_locale": "is" + }, + { + "name": "Ido", + "slug": "ido", + "iso_639_1": "io", + "iso_639_3": "ido", + "locale": "io-EN", + "android_code": "io-rEN", + "osx_code": "ido.lproj", + "osx_locale": "ido" + }, + { + "name": "Igbo", + "slug": "ig", + "iso_639_1": "ig", + "iso_639_3": "ibo", + "locale": "ig-NG", + "android_code": "ig-rNG", + "osx_code": "ig.lproj", + "osx_locale": "ig" + }, + { + "name": "Ilokano", + "slug": "ilo", + "iso_639_1": "ilo", + "iso_639_3": "ilo", + "locale": "ilo-PH", + "android_code": "ilo-rPH", + "osx_code": "ilo.lproj", + "osx_locale": "ilo" + }, + { + "name": "Indonesian", + "slug": "id", + "iso_639_1": "id", + "iso_639_3": "ind", + "locale": "id-ID", + "android_code": "in-rID", + "osx_code": "id.lproj", + "osx_locale": "id" + }, + { + "name": "Inuktitut", + "slug": "iu", + "iso_639_1": "iu", + "iso_639_3": "iku", + "locale": "iu-NU", + "android_code": "iu-rNU", + "osx_code": "iu.lproj", + "osx_locale": "iu" + }, + { + "name": "Irish", + "slug": "ga-IE", + "iso_639_1": "ga", + "iso_639_3": "gle", + "locale": "ga-IE", + "android_code": "ga-rIE", + "osx_code": "ga.lproj", + "osx_locale": "ga" + }, + { + "name": "Italian", + "slug": "it", + "iso_639_1": "it", + "iso_639_3": "ita", + "locale": "it-IT", + "android_code": "it-rIT", + "osx_code": "it.lproj", + "osx_locale": "it" + }, + { + "name": "Italian, Switzerland", + "slug": "it-CH", + "iso_639_1": "it", + "iso_639_3": "ita", + "locale": "it-CH", + "android_code": "it-rCH", + "osx_code": "it-CH.lproj", + "osx_locale": "it_CH" + }, + { + "name": "Japanese", + "slug": "ja", + "iso_639_1": "ja", + "iso_639_3": "jpn", + "locale": "ja-JP", + "android_code": "ja-rJP", + "osx_code": "ja.lproj", + "osx_locale": "ja" + }, + { + "name": "Javanese", + "slug": "jv", + "iso_639_1": "jv", + "iso_639_3": "jav", + "locale": "jv-ID", + "android_code": "jv-rID", + "osx_code": "jv.lproj", + "osx_locale": "jv" + }, + { + "name": "K'iche'", + "slug": "quc", + "iso_639_1": "quc", + "iso_639_3": "quc", + "locale": "quc-GT", + "android_code": "quc-rGT", + "osx_code": "quc.lproj", + "osx_locale": "quc" + }, + { + "name": "Kabyle", + "slug": "kab", + "iso_639_1": "kab", + "iso_639_3": "kab", + "locale": "kab-KAB", + "android_code": "kab-rKAB", + "osx_code": "kab.lproj", + "osx_locale": "kab" + }, + { + "name": "Kannada", + "slug": "kn", + "iso_639_1": "kn", + "iso_639_3": "kan", + "locale": "kn-IN", + "android_code": "kn-rIN", + "osx_code": "kn.lproj", + "osx_locale": "kn" + }, + { + "name": "Kapampangan", + "slug": "pam", + "iso_639_1": "pam", + "iso_639_3": "pam", + "locale": "pam-PH", + "android_code": "pam-rPH", + "osx_code": "pam.lproj", + "osx_locale": "pam" + }, + { + "name": "Kashmiri", + "slug": "ks", + "iso_639_1": "ks", + "iso_639_3": "kas", + "locale": "ks-IN", + "android_code": "ks-rIN", + "osx_code": "ks.lproj", + "osx_locale": "ks" + }, + { + "name": "Kashmiri, Pakistan", + "slug": "ks-PK", + "iso_639_1": "ks", + "iso_639_3": "kas", + "locale": "ks-PK", + "android_code": "ks-rPK", + "osx_code": "ks-PK.lproj", + "osx_locale": "ks_PK" + }, + { + "name": "Kashubian", + "slug": "csb", + "iso_639_1": "csb", + "iso_639_3": "csb", + "locale": "csb-PL", + "android_code": "csb-rPL", + "osx_code": "csb.lproj", + "osx_locale": "csb" + }, + { + "name": "Kazakh", + "slug": "kk", + "iso_639_1": "kk", + "iso_639_3": "kaz", + "locale": "kk-KZ", + "android_code": "kk-rKZ", + "osx_code": "kk.lproj", + "osx_locale": "kk" + }, + { + "name": "Khmer", + "slug": "km", + "iso_639_1": "km", + "iso_639_3": "khm", + "locale": "km-KH", + "android_code": "km-rKH", + "osx_code": "km.lproj", + "osx_locale": "km" + }, + { + "name": "Kinyarwanda", + "slug": "rw", + "iso_639_1": "rw", + "iso_639_3": "kin", + "locale": "rw-RW", + "android_code": "rw-rRW", + "osx_code": "rw.lproj", + "osx_locale": "rw" + }, + { + "name": "Klingon", + "slug": "tlh-AA", + "iso_639_1": "tlh", + "iso_639_3": "tlh", + "locale": "tlh-AA", + "android_code": "tlh-rAA", + "osx_code": "tlh.lproj", + "osx_locale": "tlh" + }, + { + "name": "Komi", + "slug": "kv", + "iso_639_1": "kv", + "iso_639_3": "kom", + "locale": "kv-KO", + "android_code": "kv-rKO", + "osx_code": "kv.lproj", + "osx_locale": "kv" + }, + { + "name": "Kongo", + "slug": "kg", + "iso_639_1": "kg", + "iso_639_3": "kon", + "locale": "kg-CG", + "android_code": "kg-rCG", + "osx_code": "kg.lproj", + "osx_locale": "kg" + }, + { + "name": "Konkani", + "slug": "kok", + "iso_639_1": "kok", + "iso_639_3": "kok", + "locale": "kok-IN", + "android_code": "kok-rIN", + "osx_code": "kok.lproj", + "osx_locale": "kok" + }, + { + "name": "Korean", + "slug": "ko", + "iso_639_1": "ko", + "iso_639_3": "kor", + "locale": "ko-KR", + "android_code": "ko-rKR", + "osx_code": "ko.lproj", + "osx_locale": "ko" + }, + { + "name": "Kurdish", + "slug": "ku", + "iso_639_1": "ku", + "iso_639_3": "kur", + "locale": "ku-TR", + "android_code": "ku-rTR", + "osx_code": "ku.lproj", + "osx_locale": "ku" + }, + { + "name": "Kurmanji (Kurdish)", + "slug": "kmr", + "iso_639_1": "ku", + "iso_639_3": "kmr", + "locale": "kmr-TR", + "android_code": "kmr-rTR", + "osx_code": "kmr.lproj", + "osx_locale": "kmr" + }, + { + "name": "Kwanyama", + "slug": "kj", + "iso_639_1": "kj", + "iso_639_3": "kua", + "locale": "kj-AO", + "android_code": "kj-rAO", + "osx_code": "kj.lproj", + "osx_locale": "kj" + }, + { + "name": "Kyrgyz", + "slug": "ky", + "iso_639_1": "ky", + "iso_639_3": "kir", + "locale": "ky-KG", + "android_code": "ky-rKG", + "osx_code": "ky.lproj", + "osx_locale": "ky" + }, + { + "name": "Lao", + "slug": "lo", + "iso_639_1": "lo", + "iso_639_3": "lao", + "locale": "lo-LA", + "android_code": "lo-rLA", + "osx_code": "lo.lproj", + "osx_locale": "lo" + }, + { + "name": "Latin", + "slug": "la-LA", + "iso_639_1": "la", + "iso_639_3": "lat", + "locale": "la-LA", + "android_code": "la-rLA", + "osx_code": "la.lproj", + "osx_locale": "la" + }, + { + "name": "Latvian", + "slug": "lv", + "iso_639_1": "lv", + "iso_639_3": "lav", + "locale": "lv-LV", + "android_code": "lv-rLV", + "osx_code": "lv.lproj", + "osx_locale": "lv" + }, + { + "name": "Ligurian", + "slug": "lij", + "iso_639_1": "lij", + "iso_639_3": "lij", + "locale": "lij-IT", + "android_code": "lij-rIT", + "osx_code": "lij.lproj", + "osx_locale": "lij" + }, + { + "name": "Limburgish", + "slug": "li", + "iso_639_1": "li", + "iso_639_3": "lim", + "locale": "li-LI", + "android_code": "li-rLI", + "osx_code": "li.lproj", + "osx_locale": "li" + }, + { + "name": "Lingala", + "slug": "ln", + "iso_639_1": "ln", + "iso_639_3": "lin", + "locale": "ln-CD", + "android_code": "ln-rCD", + "osx_code": "ln.lproj", + "osx_locale": "ln" + }, + { + "name": "Lithuanian", + "slug": "lt", + "iso_639_1": "lt", + "iso_639_3": "lit", + "locale": "lt-LT", + "android_code": "lt-rLT", + "osx_code": "lt.lproj", + "osx_locale": "lt" + }, + { + "name": "Lojban", + "slug": "jbo", + "iso_639_1": "jbo", + "iso_639_3": "jbo", + "locale": "jbo-EN", + "android_code": "jbo-rEN", + "osx_code": "jbo.lproj", + "osx_locale": "jbo" + }, + { + "name": "LOLCAT", + "slug": "lol", + "iso_639_1": "lol", + "iso_639_3": "lol", + "locale": "lol-US", + "android_code": "lol-rUS", + "osx_code": "lol.lproj", + "osx_locale": "lol" + }, + { + "name": "Low German", + "slug": "nds", + "iso_639_1": "nds", + "iso_639_3": "nds", + "locale": "nds-DE", + "android_code": "nds-rDE", + "osx_code": "nds.lproj", + "osx_locale": "nds" + }, + { + "name": "Lower Sorbian", + "slug": "dsb-DE", + "iso_639_1": "dsb", + "iso_639_3": "dsb", + "locale": "dsb-DE", + "android_code": "dsb-rDE", + "osx_code": "dsb.lproj", + "osx_locale": "dsb" + }, + { + "name": "Luganda", + "slug": "lg", + "iso_639_1": "lg", + "iso_639_3": "lug", + "locale": "lg-UG", + "android_code": "lg-rUG", + "osx_code": "lg.lproj", + "osx_locale": "lg" + }, + { + "name": "Luhya", + "slug": "luy", + "iso_639_1": "luy", + "iso_639_3": "luy", + "locale": "luy-KE", + "android_code": "luy-rKE", + "osx_code": "luy.lproj", + "osx_locale": "luy" + }, + { + "name": "Luxembourgish", + "slug": "lb", + "iso_639_1": "lb", + "iso_639_3": "ltz", + "locale": "lb-LU", + "android_code": "lb-rLU", + "osx_code": "lb.lproj", + "osx_locale": "lb" + }, + { + "name": "Macedonian (FYROM)", + "slug": "mk", + "iso_639_1": "mk", + "iso_639_3": "mkd", + "locale": "mk-MK", + "android_code": "mk-rMK", + "osx_code": "mk.lproj", + "osx_locale": "mk" + }, + { + "name": "Maithili", + "slug": "mai", + "iso_639_1": "mai", + "iso_639_3": "mai", + "locale": "mai-IN", + "android_code": "mai-rIN", + "osx_code": "mai.lproj", + "osx_locale": "mai" + }, + { + "name": "Malagasy", + "slug": "mg", + "iso_639_1": "mg", + "iso_639_3": "mlg", + "locale": "mg-MG", + "android_code": "mg-rMG", + "osx_code": "mg.lproj", + "osx_locale": "mg" + }, + { + "name": "Malay", + "slug": "ms", + "iso_639_1": "ms", + "iso_639_3": "msa", + "locale": "ms-MY", + "android_code": "ms-rMY", + "osx_code": "ms.lproj", + "osx_locale": "ms" + }, + { + "name": "Malay, Brunei", + "slug": "ms-BN", + "iso_639_1": "ms", + "iso_639_3": "msa", + "locale": "ms-BN", + "android_code": "ms-rBN", + "osx_code": "ms-BN.lproj", + "osx_locale": "ms_BN" + }, + { + "name": "Malayalam", + "slug": "ml-IN", + "iso_639_1": "ml", + "iso_639_3": "mal", + "locale": "ml-IN", + "android_code": "ml-rIN", + "osx_code": "ml.lproj", + "osx_locale": "ml" + }, + { + "name": "Maltese", + "slug": "mt", + "iso_639_1": "mt", + "iso_639_3": "mlt", + "locale": "mt-MT", + "android_code": "mt-rMT", + "osx_code": "mt.lproj", + "osx_locale": "mt" + }, + { + "name": "Manx", + "slug": "gv", + "iso_639_1": "gv", + "iso_639_3": "glv", + "locale": "gv-IM", + "android_code": "gv-rIM", + "osx_code": "gv.lproj", + "osx_locale": "gv" + }, + { + "name": "Maori", + "slug": "mi", + "iso_639_1": "mi", + "iso_639_3": "mri", + "locale": "mi-NZ", + "android_code": "mi-rNZ", + "osx_code": "mi.lproj", + "osx_locale": "mi" + }, + { + "name": "Mapudungun", + "slug": "arn", + "iso_639_1": "arn", + "iso_639_3": "arn", + "locale": "arn-CL", + "android_code": "arn-rCL", + "osx_code": "arn.lproj", + "osx_locale": "arn" + }, + { + "name": "Marathi", + "slug": "mr", + "iso_639_1": "mr", + "iso_639_3": "mar", + "locale": "mr-IN", + "android_code": "mr-rIN", + "osx_code": "mr.lproj", + "osx_locale": "mr" + }, + { + "name": "Marshallese", + "slug": "mh", + "iso_639_1": "mh", + "iso_639_3": "mah", + "locale": "mh-MH", + "android_code": "mh-rMH", + "osx_code": "mh.lproj", + "osx_locale": "mh" + }, + { + "name": "Mohawk", + "slug": "moh", + "iso_639_1": "moh", + "iso_639_3": "moh", + "locale": "moh-CA", + "android_code": "moh-rCA", + "osx_code": "moh.lproj", + "osx_locale": "moh" + }, + { + "name": "Mongolian", + "slug": "mn", + "iso_639_1": "mn", + "iso_639_3": "mon", + "locale": "mn-MN", + "android_code": "mn-rMN", + "osx_code": "mn.lproj", + "osx_locale": "mn" + }, + { + "name": "Montenegrin (Cyrillic)", + "slug": "sr-Cyrl-ME", + "iso_639_1": "sr", + "iso_639_3": "srp", + "locale": "sr-Cyrl-ME", + "android_code": "sr-rCyrl-rME", + "osx_code": "sr-Cyrl-ME.lproj", + "osx_locale": "sr_Cyrl_ME" + }, + { + "name": "Montenegrin (Latin)", + "slug": "me", + "iso_639_1": "me", + "iso_639_3": "srp", + "locale": "me-ME", + "android_code": "me-rME", + "osx_code": "me.lproj", + "osx_locale": "me" + }, + { + "name": "Mossi", + "slug": "mos", + "iso_639_1": "mos", + "iso_639_3": "mos", + "locale": "mos-MOS", + "android_code": "mos-rMOS", + "osx_code": "mos.lproj", + "osx_locale": "mos" + }, + { + "name": "Nauru", + "slug": "na", + "iso_639_1": "na", + "iso_639_3": "nau", + "locale": "na-NR", + "android_code": "na-rNR", + "osx_code": "na.lproj", + "osx_locale": "na" + }, + { + "name": "Ndonga", + "slug": "ng", + "iso_639_1": "ng", + "iso_639_3": "ndo", + "locale": "ng-NA", + "android_code": "ng-rNA", + "osx_code": "ng.lproj", + "osx_locale": "ng" + }, + { + "name": "Nepali", + "slug": "ne-NP", + "iso_639_1": "ne", + "iso_639_3": "nep", + "locale": "ne-NP", + "android_code": "ne-rNP", + "osx_code": "ne.lproj", + "osx_locale": "ne" + }, + { + "name": "Nepali, India", + "slug": "ne-IN", + "iso_639_1": "ne", + "iso_639_3": "nep", + "locale": "ne-IN", + "android_code": "ne-rIN", + "osx_code": "ne-IN.lproj", + "osx_locale": "ne_IN" + }, + { + "name": "Nigerian Pidgin", + "slug": "pcm", + "iso_639_1": "pcm", + "iso_639_3": "pcm", + "locale": "pcm-NG", + "android_code": "pcm-rNG", + "osx_code": "pcm.lproj", + "osx_locale": "pcm" + }, + { + "name": "Northern Sami", + "slug": "se", + "iso_639_1": "se", + "iso_639_3": "sme", + "locale": "se-NO", + "android_code": "se-rNO", + "osx_code": "se.lproj", + "osx_locale": "se" + }, + { + "name": "Northern Sotho", + "slug": "nso", + "iso_639_1": "nso", + "iso_639_3": "nso", + "locale": "ns-ZA", + "android_code": "ns-rZA", + "osx_code": "nso.lproj", + "osx_locale": "nso" + }, + { + "name": "Norwegian", + "slug": "no", + "iso_639_1": "no", + "iso_639_3": "nor", + "locale": "no-NO", + "android_code": "no-rNO", + "osx_code": "no.lproj", + "osx_locale": "no" + }, + { + "name": "Norwegian Bokmal", + "slug": "nb", + "iso_639_1": "nb", + "iso_639_3": "nob", + "locale": "nb-NO", + "android_code": "nb-rNO", + "osx_code": "nb.lproj", + "osx_locale": "nb" + }, + { + "name": "Norwegian Nynorsk", + "slug": "nn-NO", + "iso_639_1": "nn", + "iso_639_3": "nno", + "locale": "nn-NO", + "android_code": "nn-rNO", + "osx_code": "nn-NO.lproj", + "osx_locale": "nn_NO" + }, + { + "name": "Occitan", + "slug": "oc", + "iso_639_1": "oc", + "iso_639_3": "oci", + "locale": "oc-FR", + "android_code": "oc-rFR", + "osx_code": "oc.lproj", + "osx_locale": "oc" + }, + { + "name": "Ojibwe", + "slug": "oj", + "iso_639_1": "oj", + "iso_639_3": "oji", + "locale": "oj-CA", + "android_code": "oj-rCA", + "osx_code": "oj.lproj", + "osx_locale": "oj" + }, + { + "name": "Oriya", + "slug": "or", + "iso_639_1": "or", + "iso_639_3": "ori", + "locale": "or-IN", + "android_code": "or-rIN", + "osx_code": "or.lproj", + "osx_locale": "or" + }, + { + "name": "Oromo", + "slug": "om", + "iso_639_1": "om", + "iso_639_3": "orm", + "locale": "om-ET", + "android_code": "om-rET", + "osx_code": "om.lproj", + "osx_locale": "om" + }, + { + "name": "Ossetian", + "slug": "os", + "iso_639_1": "os", + "iso_639_3": "oss", + "locale": "os-SE", + "android_code": "os-rSE", + "osx_code": "os.lproj", + "osx_locale": "os" + }, + { + "name": "Pali", + "slug": "pi", + "iso_639_1": "pi", + "iso_639_3": "pli", + "locale": "pi-IN", + "android_code": "pi-rIN", + "osx_code": "pi.lproj", + "osx_locale": "pi" + }, + { + "name": "Papiamento", + "slug": "pap", + "iso_639_1": "pap", + "iso_639_3": "pap", + "locale": "pap-PAP", + "android_code": "pap-rPAP", + "osx_code": "pap.lproj", + "osx_locale": "pap" + }, + { + "name": "Pashto", + "slug": "ps", + "iso_639_1": "ps", + "iso_639_3": "pus", + "locale": "ps-AF", + "android_code": "ps-rAF", + "osx_code": "ps.lproj", + "osx_locale": "ps" + }, + { + "name": "Persian", + "slug": "fa", + "iso_639_1": "fa", + "iso_639_3": "fas", + "locale": "fa-IR", + "android_code": "fa-rIR", + "osx_code": "fa.lproj", + "osx_locale": "fa" + }, + { + "name": "Pirate English", + "slug": "en-PT", + "iso_639_1": "en", + "iso_639_3": "eng", + "locale": "en-PT", + "android_code": "en-rPT", + "osx_code": "en-PT.lproj", + "osx_locale": "en_PT" + }, + { + "name": "Polish", + "slug": "pl", + "iso_639_1": "pl", + "iso_639_3": "pol", + "locale": "pl-PL", + "android_code": "pl-rPL", + "osx_code": "pl.lproj", + "osx_locale": "pl" + }, + { + "name": "Portuguese", + "slug": "pt-PT", + "iso_639_1": "pt", + "iso_639_3": "por", + "locale": "pt-PT", + "android_code": "pt-rPT", + "osx_code": "pt.lproj", + "osx_locale": "pt" + }, + { + "name": "Portuguese, Brazilian", + "slug": "pt-BR", + "iso_639_1": "pt", + "iso_639_3": "por", + "locale": "pt-BR", + "android_code": "pt-rBR", + "osx_code": "pt-BR.lproj", + "osx_locale": "pt_BR" + }, + { + "name": "Punjabi", + "slug": "pa-IN", + "iso_639_1": "pa", + "iso_639_3": "pan", + "locale": "pa-IN", + "android_code": "pa-rIN", + "osx_code": "pa.lproj", + "osx_locale": "pa" + }, + { + "name": "Punjabi, Pakistan", + "slug": "pa-PK", + "iso_639_1": "pa", + "iso_639_3": "pan", + "locale": "pa-PK", + "android_code": "pa-rPK", + "osx_code": "pa-PK.lproj", + "osx_locale": "pa_PK" + }, + { + "name": "Quechua", + "slug": "qu", + "iso_639_1": "qu", + "iso_639_3": "que", + "locale": "qu-PE", + "android_code": "qu-rPE", + "osx_code": "qu.lproj", + "osx_locale": "qu" + }, + { + "name": "Quenya", + "slug": "qya-AA", + "iso_639_1": "qya", + "iso_639_3": "qya", + "locale": "qya-AA", + "android_code": "qya-rAA", + "osx_code": "qya.lproj", + "osx_locale": "qya" + }, + { + "name": "Romanian", + "slug": "ro", + "iso_639_1": "ro", + "iso_639_3": "ron", + "locale": "ro-RO", + "android_code": "ro-rRO", + "osx_code": "ro.lproj", + "osx_locale": "ro" + }, + { + "name": "Romansh", + "slug": "rm-CH", + "iso_639_1": "rm", + "iso_639_3": "roh", + "locale": "rm-CH", + "android_code": "rm-rCH", + "osx_code": "rm.lproj", + "osx_locale": "rm" + }, + { + "name": "Rundi", + "slug": "rn", + "iso_639_1": "rn", + "iso_639_3": "run", + "locale": "rn-BI", + "android_code": "rn-rBI", + "osx_code": "rn.lproj", + "osx_locale": "rn" + }, + { + "name": "Russian", + "slug": "ru", + "iso_639_1": "ru", + "iso_639_3": "rus", + "locale": "ru-RU", + "android_code": "ru-rRU", + "osx_code": "ru.lproj", + "osx_locale": "ru" + }, + { + "name": "Russian, Belarus", + "slug": "ru-BY", + "iso_639_1": "ru", + "iso_639_3": "rus", + "locale": "ru-BY", + "android_code": "ru-rBY", + "osx_code": "ru-BY.lproj", + "osx_locale": "ru_BY" + }, + { + "name": "Russian, Moldova", + "slug": "ru-MD", + "iso_639_1": "ru", + "iso_639_3": "rus", + "locale": "ru-MD", + "android_code": "ru-rMD", + "osx_code": "ru-MD.lproj", + "osx_locale": "ru_MD" + }, + { + "name": "Russian, Ukraine", + "slug": "ru-UA", + "iso_639_1": "ru", + "iso_639_3": "rus", + "locale": "ru-UA", + "android_code": "ru-rUA", + "osx_code": "ru-UA.lproj", + "osx_locale": "ru_UA" + }, + { + "name": "Rusyn", + "slug": "ry-UA", + "iso_639_1": "ry", + "iso_639_3": "sla", + "locale": "ry-UA", + "android_code": "ry-rUA", + "osx_code": "ry.lproj", + "osx_locale": "ry" + }, + { + "name": "Sakha", + "slug": "sah", + "iso_639_1": "sah", + "iso_639_3": "sah", + "locale": "sah-SAH", + "android_code": "sah-rSAH", + "osx_code": "sah.lproj", + "osx_locale": "sah" + }, + { + "name": "Sango", + "slug": "sg", + "iso_639_1": "sg", + "iso_639_3": "sag", + "locale": "sg-CF", + "android_code": "sg-rCF", + "osx_code": "sg.lproj", + "osx_locale": "sg" + }, + { + "name": "Sanskrit", + "slug": "sa", + "iso_639_1": "sa", + "iso_639_3": "san", + "locale": "sa-IN", + "android_code": "sa-rIN", + "osx_code": "sa.lproj", + "osx_locale": "sa" + }, + { + "name": "Santali", + "slug": "sat", + "iso_639_1": "sat", + "iso_639_3": "sat", + "locale": "sat-IN", + "android_code": "sat-rIN", + "osx_code": "sat.lproj", + "osx_locale": "sat" + }, + { + "name": "Sardinian", + "slug": "sc", + "iso_639_1": "sc", + "iso_639_3": "srd", + "locale": "sc-IT", + "android_code": "sc-rIT", + "osx_code": "sc.lproj", + "osx_locale": "sc" + }, + { + "name": "Scots", + "slug": "sco", + "iso_639_1": "sco", + "iso_639_3": "sco", + "locale": "sco-GB", + "android_code": "sco-rGB", + "osx_code": "sco.lproj", + "osx_locale": "sco" + }, + { + "name": "Scottish Gaelic", + "slug": "gd", + "iso_639_1": "gd", + "iso_639_3": "gla", + "locale": "gd-GB", + "android_code": "gd-rGB", + "osx_code": "gd.lproj", + "osx_locale": "gd" + }, + { + "name": "Serbian (Cyrillic)", + "slug": "sr", + "iso_639_1": "sr", + "iso_639_3": "srp", + "locale": "sr-SP", + "android_code": "sr-rSP", + "osx_code": "sr.lproj", + "osx_locale": "sr" + }, + { + "name": "Serbian (Latin)", + "slug": "sr-CS", + "iso_639_1": "sr", + "iso_639_3": "srp", + "locale": "sr-CS", + "android_code": "sr-rCS", + "osx_code": "sr-CS.lproj", + "osx_locale": "sr_CS" + }, + { + "name": "Serbo-Croatian", + "slug": "sh", + "iso_639_1": "sh", + "iso_639_3": "hbs", + "locale": "sh-HR", + "android_code": "sh-rHR", + "osx_code": "sh.lproj", + "osx_locale": "sh" + }, + { + "name": "Seychellois Creole", + "slug": "crs", + "iso_639_1": "crs", + "iso_639_3": "crs", + "locale": "crs-SC", + "android_code": "crs-rSC", + "osx_code": "crs.lproj", + "osx_locale": "crs" + }, + { + "name": "Shona", + "slug": "sn", + "iso_639_1": "sn", + "iso_639_3": "sna", + "locale": "sn-ZW", + "android_code": "sn-rZW", + "osx_code": "sn.lproj", + "osx_locale": "sn" + }, + { + "name": "Sichuan Yi", + "slug": "ii", + "iso_639_1": "ii", + "iso_639_3": "iii", + "locale": "ii-CN", + "android_code": "ii-rCN", + "osx_code": "ii.lproj", + "osx_locale": "ii" + }, + { + "name": "Sindhi", + "slug": "sd", + "iso_639_1": "sd", + "iso_639_3": "snd", + "locale": "sd-PK", + "android_code": "sd-rPK", + "osx_code": "sd.lproj", + "osx_locale": "sd" + }, + { + "name": "Sinhala", + "slug": "si-LK", + "iso_639_1": "si", + "iso_639_3": "sin", + "locale": "si-LK", + "android_code": "si-rLK", + "osx_code": "si.lproj", + "osx_locale": "si" + }, + { + "name": "Slovak", + "slug": "sk", + "iso_639_1": "sk", + "iso_639_3": "slk", + "locale": "sk-SK", + "android_code": "sk-rSK", + "osx_code": "sk.lproj", + "osx_locale": "sk" + }, + { + "name": "Slovenian", + "slug": "sl", + "iso_639_1": "sl", + "iso_639_3": "slv", + "locale": "sl-SI", + "android_code": "sl-rSI", + "osx_code": "sl.lproj", + "osx_locale": "sl" + }, + { + "name": "Somali", + "slug": "so", + "iso_639_1": "so", + "iso_639_3": "som", + "locale": "so-SO", + "android_code": "so-rSO", + "osx_code": "so.lproj", + "osx_locale": "so" + }, + { + "name": "Songhay", + "slug": "son", + "iso_639_1": "son", + "iso_639_3": "son", + "locale": "son-ZA", + "android_code": "son-rZA", + "osx_code": "son.lproj", + "osx_locale": "son" + }, + { + "name": "Sorani (Kurdish)", + "slug": "ckb", + "iso_639_1": "ku", + "iso_639_3": "ckb", + "locale": "ckb-IR", + "android_code": "ckb-rIR", + "osx_code": "ckb.lproj", + "osx_locale": "ckb" + }, + { + "name": "Southern Ndebele", + "slug": "nr", + "iso_639_1": "nr", + "iso_639_3": "nbl", + "locale": "nr-ZA", + "android_code": "nr-rZA", + "osx_code": "nr.lproj", + "osx_locale": "nr" + }, + { + "name": "Southern Sami", + "slug": "sma", + "iso_639_1": "sma", + "iso_639_3": "sma", + "locale": "sma-NO", + "android_code": "sma-rNO", + "osx_code": "sma.lproj", + "osx_locale": "sma" + }, + { + "name": "Southern Sotho", + "slug": "st", + "iso_639_1": "st", + "iso_639_3": "sot", + "locale": "st-ZA", + "android_code": "st-rZA", + "osx_code": "st.lproj", + "osx_locale": "st" + }, + { + "name": "Spanish", + "slug": "es-ES", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-ES", + "android_code": "es-rES", + "osx_code": "es.lproj", + "osx_locale": "es" + }, + { + "name": "Spanish (Modern)", + "slug": "es-EM", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-EM", + "android_code": "es-rEM", + "osx_code": "es-EM.lproj", + "osx_locale": "es_EM" + }, + { + "name": "Spanish, Argentina", + "slug": "es-AR", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-AR", + "android_code": "es-rAR", + "osx_code": "es-AR.lproj", + "osx_locale": "es_AR" + }, + { + "name": "Spanish, Bolivia", + "slug": "es-BO", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-BO", + "android_code": "es-rBO", + "osx_code": "es-BO.lproj", + "osx_locale": "es_BO" + }, + { + "name": "Spanish, Chile", + "slug": "es-CL", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-CL", + "android_code": "es-rCL", + "osx_code": "es-CL.lproj", + "osx_locale": "es_CL" + }, + { + "name": "Spanish, Colombia", + "slug": "es-CO", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-CO", + "android_code": "es-rCO", + "osx_code": "es-CO.lproj", + "osx_locale": "es_CO" + }, + { + "name": "Spanish, Costa Rica", + "slug": "es-CR", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-CR", + "android_code": "es-rCR", + "osx_code": "es-CR.lproj", + "osx_locale": "es_CR" + }, + { + "name": "Spanish, Dominican Republic", + "slug": "es-DO", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-DO", + "android_code": "es-rDO", + "osx_code": "es-DO.lproj", + "osx_locale": "es_DO" + }, + { + "name": "Spanish, Ecuador", + "slug": "es-EC", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-EC", + "android_code": "es-rEC", + "osx_code": "es-EC.lproj", + "osx_locale": "es_EC" + }, + { + "name": "Spanish, El Salvador", + "slug": "es-SV", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-SV", + "android_code": "es-rSV", + "osx_code": "es-SV.lproj", + "osx_locale": "es_SV" + }, + { + "name": "Spanish, Guatemala", + "slug": "es-GT", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-GT", + "android_code": "es-rGT", + "osx_code": "es-GT.lproj", + "osx_locale": "es_GT" + }, + { + "name": "Spanish, Honduras", + "slug": "es-HN", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-HN", + "android_code": "es-rHN", + "osx_code": "es-HN.lproj", + "osx_locale": "es_HN" + }, + { + "name": "Spanish, Mexico", + "slug": "es-MX", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-MX", + "android_code": "es-rMX", + "osx_code": "es-MX.lproj", + "osx_locale": "es_MX" + }, + { + "name": "Spanish, Nicaragua", + "slug": "es-NI", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-NI", + "android_code": "es-rNI", + "osx_code": "es-NI.lproj", + "osx_locale": "es_NI" + }, + { + "name": "Spanish, Panama", + "slug": "es-PA", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-PA", + "android_code": "es-rPA", + "osx_code": "es-PA.lproj", + "osx_locale": "es_PA" + }, + { + "name": "Spanish, Paraguay", + "slug": "es-PY", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-PY", + "android_code": "es-rPY", + "osx_code": "es-PY.lproj", + "osx_locale": "es_PY" + }, + { + "name": "Spanish, Peru", + "slug": "es-PE", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-PE", + "android_code": "es-rPE", + "osx_code": "es-PE.lproj", + "osx_locale": "es_PE" + }, + { + "name": "Spanish, Puerto Rico", + "slug": "es-PR", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-PR", + "android_code": "es-rPR", + "osx_code": "es-PR.lproj", + "osx_locale": "es_PR" + }, + { + "name": "Spanish, United States", + "slug": "es-US", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-US", + "android_code": "es-rUS", + "osx_code": "es-US.lproj", + "osx_locale": "es_US" + }, + { + "name": "Spanish, Uruguay", + "slug": "es-UY", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-UY", + "android_code": "es-rUY", + "osx_code": "es-UY.lproj", + "osx_locale": "es_UY" + }, + { + "name": "Spanish, Venezuela", + "slug": "es-VE", + "iso_639_1": "es", + "iso_639_3": "spa", + "locale": "es-VE", + "android_code": "es-rVE", + "osx_code": "es-VE.lproj", + "osx_locale": "es_VE" + }, + { + "name": "Sundanese", + "slug": "su", + "iso_639_1": "su", + "iso_639_3": "sun", + "locale": "su-ID", + "android_code": "su-rID", + "osx_code": "su.lproj", + "osx_locale": "su" + }, + { + "name": "Swahili", + "slug": "sw", + "iso_639_1": "sw", + "iso_639_3": "swa", + "locale": "sw-KE", + "android_code": "sw-rKE", + "osx_code": "sw.lproj", + "osx_locale": "sw" + }, + { + "name": "Swahili, Kenya", + "slug": "sw-KE", + "iso_639_1": "sw", + "iso_639_3": "swa", + "locale": "sw-KE", + "android_code": "sw-rKE", + "osx_code": "sw-KE.lproj", + "osx_locale": "sw_KE" + }, + { + "name": "Swahili, Tanzania", + "slug": "sw-TZ", + "iso_639_1": "sw", + "iso_639_3": "swa", + "locale": "sw-TZ", + "android_code": "sw-rTZ", + "osx_code": "sw-TZ.lproj", + "osx_locale": "sw_TZ" + }, + { + "name": "Swati", + "slug": "ss", + "iso_639_1": "ss", + "iso_639_3": "ssw", + "locale": "ss-ZA", + "android_code": "ss-rZA", + "osx_code": "ss.lproj", + "osx_locale": "ss" + }, + { + "name": "Swedish", + "slug": "sv-SE", + "iso_639_1": "sv", + "iso_639_3": "swe", + "locale": "sv-SE", + "android_code": "sv-rSE", + "osx_code": "sv.lproj", + "osx_locale": "sv" + }, + { + "name": "Swedish, Finland", + "slug": "sv-FI", + "iso_639_1": "sv", + "iso_639_3": "swe", + "locale": "sv-FI", + "android_code": "sv-rFI", + "osx_code": "sv-FI.lproj", + "osx_locale": "sv_FI" + }, + { + "name": "Syriac", + "slug": "syc", + "iso_639_1": "syc", + "iso_639_3": "syc", + "locale": "syc-SY", + "android_code": "syc-rSY", + "osx_code": "syc.lproj", + "osx_locale": "syc" + }, + { + "name": "Tagalog", + "slug": "tl", + "iso_639_1": "tl", + "iso_639_3": "tgl", + "locale": "tl-PH", + "android_code": "tl-rPH", + "osx_code": "tl.lproj", + "osx_locale": "tl" + }, + { + "name": "Tahitian", + "slug": "ty", + "iso_639_1": "ty", + "iso_639_3": "tah", + "locale": "ty-PF", + "android_code": "ty-rPF", + "osx_code": "ty.lproj", + "osx_locale": "ty" + }, + { + "name": "Tajik", + "slug": "tg", + "iso_639_1": "tg", + "iso_639_3": "tgk", + "locale": "tg-TJ", + "android_code": "tg-rTJ", + "osx_code": "tg.lproj", + "osx_locale": "tg" + }, + { + "name": "Talossan", + "slug": "tzl", + "iso_639_1": "tzl", + "iso_639_3": "tzl", + "locale": "tzl-TZL", + "android_code": "tzl-rTZL", + "osx_code": "tzl.lproj", + "osx_locale": "tzl" + }, + { + "name": "Tamil", + "slug": "ta", + "iso_639_1": "ta", + "iso_639_3": "tam", + "locale": "ta-IN", + "android_code": "ta-rIN", + "osx_code": "ta.lproj", + "osx_locale": "ta" + }, + { + "name": "Tatar", + "slug": "tt-RU", + "iso_639_1": "tt", + "iso_639_3": "tat", + "locale": "tt-RU", + "android_code": "tt-rRU", + "osx_code": "tt.lproj", + "osx_locale": "tt" + }, + { + "name": "Telugu", + "slug": "te", + "iso_639_1": "te", + "iso_639_3": "tel", + "locale": "te-IN", + "android_code": "te-rIN", + "osx_code": "te.lproj", + "osx_locale": "te" + }, + { + "name": "Tem (Kotokoli)", + "slug": "kdh", + "iso_639_1": "kdh", + "iso_639_3": "kdh", + "locale": "kdh-KDH", + "android_code": "kdh-rKDH", + "osx_code": "kdh.lproj", + "osx_locale": "kdh" + }, + { + "name": "Thai", + "slug": "th", + "iso_639_1": "th", + "iso_639_3": "tha", + "locale": "th-TH", + "android_code": "th-rTH", + "osx_code": "th.lproj", + "osx_locale": "th" + }, + { + "name": "Tibetan", + "slug": "bo-BT", + "iso_639_1": "bo", + "iso_639_3": "tib", + "locale": "bo-BT", + "android_code": "bo-rBT", + "osx_code": "bo.lproj", + "osx_locale": "bo" + }, + { + "name": "Tigrinya", + "slug": "ti", + "iso_639_1": "ti", + "iso_639_3": "tir", + "locale": "ti-ER", + "android_code": "ti-rER", + "osx_code": "ti.lproj", + "osx_locale": "ti" + }, + { + "name": "Tsonga", + "slug": "ts", + "iso_639_1": "ts", + "iso_639_3": "tso", + "locale": "ts-ZA", + "android_code": "ts-rZA", + "osx_code": "ts.lproj", + "osx_locale": "ts" + }, + { + "name": "Tswana", + "slug": "tn", + "iso_639_1": "tn", + "iso_639_3": "tsn", + "locale": "tn-ZA", + "android_code": "tn-rZA", + "osx_code": "tn.lproj", + "osx_locale": "tn" + }, + { + "name": "Turkish", + "slug": "tr", + "iso_639_1": "tr", + "iso_639_3": "tur", + "locale": "tr-TR", + "android_code": "tr-rTR", + "osx_code": "tr.lproj", + "osx_locale": "tr" + }, + { + "name": "Turkish, Cyprus", + "slug": "tr-CY", + "iso_639_1": "tr", + "iso_639_3": "tur", + "locale": "tr-CY", + "android_code": "tr-rCY", + "osx_code": "tr-CY.lproj", + "osx_locale": "tr_CY" + }, + { + "name": "Turkmen", + "slug": "tk", + "iso_639_1": "tk", + "iso_639_3": "tuk", + "locale": "tk-TM", + "android_code": "tk-rTM", + "osx_code": "tk.lproj", + "osx_locale": "tk" + }, + { + "name": "Ukrainian", + "slug": "uk", + "iso_639_1": "uk", + "iso_639_3": "ukr", + "locale": "uk-UA", + "android_code": "uk-rUA", + "osx_code": "uk.lproj", + "osx_locale": "uk" + }, + { + "name": "Upper Sorbian", + "slug": "hsb-DE", + "iso_639_1": "hsb", + "iso_639_3": "hsb", + "locale": "hsb-DE", + "android_code": "hsb-rDE", + "osx_code": "hsb.lproj", + "osx_locale": "hsb" + }, + { + "name": "Urdu (India)", + "slug": "ur-IN", + "iso_639_1": "ur", + "iso_639_3": "urd", + "locale": "ur-IN", + "android_code": "ur-rIN", + "osx_code": "ur-IN.lproj", + "osx_locale": "ur_IN" + }, + { + "name": "Urdu (Pakistan)", + "slug": "ur-PK", + "iso_639_1": "ur", + "iso_639_3": "urd", + "locale": "ur-PK", + "android_code": "ur-rPK", + "osx_code": "ur.lproj", + "osx_locale": "ur" + }, + { + "name": "Uyghur", + "slug": "ug", + "iso_639_1": "ug", + "iso_639_3": "uig", + "locale": "ug-CN", + "android_code": "ug-rCN", + "osx_code": "ug.lproj", + "osx_locale": "ug" + }, + { + "name": "Uzbek", + "slug": "uz", + "iso_639_1": "uz", + "iso_639_3": "uzb", + "locale": "uz-UZ", + "android_code": "uz-rUZ", + "osx_code": "uz.lproj", + "osx_locale": "uz" + }, + { + "name": "Valencian", + "slug": "val-ES", + "iso_639_1": "val", + "iso_639_3": "val", + "locale": "val-ES", + "android_code": "val-rES", + "osx_code": "val.lproj", + "osx_locale": "val" + }, + { + "name": "Venda", + "slug": "ve", + "iso_639_1": "ve", + "iso_639_3": "ven", + "locale": "ve-ZA", + "android_code": "ve-rZA", + "osx_code": "ve.lproj", + "osx_locale": "ve" + }, + { + "name": "Venetian", + "slug": "vec", + "iso_639_1": "vec", + "iso_639_3": "vec", + "locale": "vec-IT", + "android_code": "vec-rIT", + "osx_code": "vec.lproj", + "osx_locale": "vec" + }, + { + "name": "Vietnamese", + "slug": "vi", + "iso_639_1": "vi", + "iso_639_3": "vie", + "locale": "vi-VN", + "android_code": "vi-rVN", + "osx_code": "vi.lproj", + "osx_locale": "vi" + }, + { + "name": "Walloon", + "slug": "wa", + "iso_639_1": "wa", + "iso_639_3": "wln", + "locale": "wa-BE", + "android_code": "wa-rBE", + "osx_code": "wa.lproj", + "osx_locale": "wa" + }, + { + "name": "Welsh", + "slug": "cy", + "iso_639_1": "cy", + "iso_639_3": "cym", + "locale": "cy-GB", + "android_code": "cy-rGB", + "osx_code": "cy.lproj", + "osx_locale": "cy" + }, + { + "name": "Wolof", + "slug": "wo", + "iso_639_1": "wo", + "iso_639_3": "wol", + "locale": "wo-SN", + "android_code": "wo-rSN", + "osx_code": "wo.lproj", + "osx_locale": "wo" + }, + { + "name": "Xhosa", + "slug": "xh", + "iso_639_1": "xh", + "iso_639_3": "xho", + "locale": "xh-ZA", + "android_code": "xh-rZA", + "osx_code": "xh.lproj", + "osx_locale": "xh" + }, + { + "name": "Yiddish", + "slug": "yi", + "iso_639_1": "yi", + "iso_639_3": "yid", + "locale": "yi-DE", + "android_code": "ji-rDE", + "osx_code": "yi.lproj", + "osx_locale": "yi" + }, + { + "name": "Yoruba", + "slug": "yo", + "iso_639_1": "yo", + "iso_639_3": "yor", + "locale": "yo-NG", + "android_code": "yo-rNG", + "osx_code": "yo.lproj", + "osx_locale": "yo" + }, + { + "name": "Zeelandic", + "slug": "zea", + "iso_639_1": "zea", + "iso_639_3": "zea", + "locale": "zea-ZEA", + "android_code": "zea-rZEA", + "osx_code": "zea.lproj", + "osx_locale": "zea" + }, + { + "name": "Zulu", + "slug": "zu", + "iso_639_1": "zu", + "iso_639_3": "zul", + "locale": "zu-ZA", + "android_code": "zu-rZA", + "osx_code": "zu.lproj", + "osx_locale": "zu" + } +] diff --git a/priv/repo/migrations/20150929150000_create_languages.exs b/priv/repo/migrations/20150929150000_create_languages.exs new file mode 100644 index 00000000..17131132 --- /dev/null +++ b/priv/repo/migrations/20150929150000_create_languages.exs @@ -0,0 +1,13 @@ +defmodule Accent.Repo.Migrations.CreateLanguages do + use Ecto.Migration + + def change do + create table(:languages, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + add :slug, :string + + timestamps + end + end +end diff --git a/priv/repo/migrations/20150929150001_create_projects.exs b/priv/repo/migrations/20150929150001_create_projects.exs new file mode 100644 index 00000000..12404941 --- /dev/null +++ b/priv/repo/migrations/20150929150001_create_projects.exs @@ -0,0 +1,13 @@ +defmodule Accent.Repo.Migrations.CreateProjects do + use Ecto.Migration + + def change do + create table(:projects, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + add :language_id, references(:languages, type: :uuid) + + timestamps + end + end +end diff --git a/priv/repo/migrations/20150929150002_create_revisions.exs b/priv/repo/migrations/20150929150002_create_revisions.exs new file mode 100644 index 00000000..4b3cd1b9 --- /dev/null +++ b/priv/repo/migrations/20150929150002_create_revisions.exs @@ -0,0 +1,19 @@ +defmodule Accent.Repo.Migrations.CreateRevisions do + use Ecto.Migration + + def change do + create table(:revisions, primary_key: false) do + add :id, :uuid, primary_key: true + add :project_id, references(:projects, type: :uuid) + add :language_id, references(:languages, type: :uuid) + + add :master, :boolean, [default: true] + + timestamps + end + + alter table(:revisions) do + add :master_revision_id, references(:revisions, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20150929160926_create_translations.exs b/priv/repo/migrations/20150929160926_create_translations.exs new file mode 100644 index 00000000..067a47ec --- /dev/null +++ b/priv/repo/migrations/20150929160926_create_translations.exs @@ -0,0 +1,23 @@ +defmodule Accent.Repo.Migrations.CreateTranslations do + use Ecto.Migration + + def change do + create table(:translations, primary_key: false) do + add :id, :uuid, primary_key: true + add :key, :text + add :proposed_text, :text + add :corrected_text, :text + add :conflicted_text, :text + add :conflicted, :boolean, [default: false] + add :removed, :boolean, [default: false] + + add :revision_id, references(:revisions, type: :uuid) + + timestamps + end + + create index(:translations, [:key]) + create index(:translations, [:revision_id, :conflicted]) + create index(:translations, [:revision_id, :removed]) + end +end diff --git a/priv/repo/migrations/20151019020854_create_comments.exs b/priv/repo/migrations/20151019020854_create_comments.exs new file mode 100644 index 00000000..976c3e7a --- /dev/null +++ b/priv/repo/migrations/20151019020854_create_comments.exs @@ -0,0 +1,14 @@ +defmodule Accent.Repo.Migrations.CreateComments do + use Ecto.Migration + + def change do + create table(:comments, primary_key: false) do + add :id, :uuid, primary_key: true + add :text, :text + + add :translation_id, references(:translations, type: :uuid) + + timestamps + end + end +end diff --git a/priv/repo/migrations/20151019020855_create_operations.exs b/priv/repo/migrations/20151019020855_create_operations.exs new file mode 100644 index 00000000..6936a117 --- /dev/null +++ b/priv/repo/migrations/20151019020855_create_operations.exs @@ -0,0 +1,27 @@ +defmodule Accent.Repo.Migrations.CreateOperations do + use Ecto.Migration + + def change do + create table(:operations, primary_key: false) do + add :id, :uuid, primary_key: true + add :action, :string + add :key, :text + add :text, :text + add :previous_translation, :json + + add :translation_id, references(:translations, type: :uuid) + add :revision_id, references(:revisions, type: :uuid) + add :project_id, references(:projects, type: :uuid) + add :comment_id, references(:comments, type: :uuid) + + add :batch, :boolean, [default: false] + + timestamps + end + + alter table(:operations) do + add :batch_operation_id, references(:operations, type: :uuid) + add :from_operation_id, references(:operations, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20151022000813_create_users.exs b/priv/repo/migrations/20151022000813_create_users.exs new file mode 100644 index 00000000..69cd5e89 --- /dev/null +++ b/priv/repo/migrations/20151022000813_create_users.exs @@ -0,0 +1,43 @@ +defmodule Accent.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users, primary_key: false) do + add :id, :uuid, primary_key: true + add :email, :string + add :fullname, :string + + timestamps + end + + create table(:auth_providers, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + add :uid, :text + + add :user_id, references(:users, type: :uuid) + + timestamps + end + + create table(:auth_applications, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + + timestamps + end + + create table(:auth_access_tokens, primary_key: false) do + add :id, :uuid, primary_key: true + add :token, :string + add :user_id, references(:users, type: :uuid) + add :auth_application_id, references(:auth_applications, type: :uuid) + + add :revoked_at, :utc_datetime + + timestamps + end + + create index(:auth_access_tokens, [:token]) + end +end diff --git a/priv/repo/migrations/20151026012626_add_user_id_to_operations.exs b/priv/repo/migrations/20151026012626_add_user_id_to_operations.exs new file mode 100644 index 00000000..66bd4e52 --- /dev/null +++ b/priv/repo/migrations/20151026012626_add_user_id_to_operations.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddUserIdToOperations do + use Ecto.Migration + + def change do + alter table(:operations) do + add :user_id, references(:users, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20151217004603_add_unique_constraint_revisions_language_project.exs b/priv/repo/migrations/20151217004603_add_unique_constraint_revisions_language_project.exs new file mode 100644 index 00000000..4eb83eac --- /dev/null +++ b/priv/repo/migrations/20151217004603_add_unique_constraint_revisions_language_project.exs @@ -0,0 +1,7 @@ +defmodule Accent.Repo.Migrations.AddUniqueConstraintRevisionsLanguageProject do + use Ecto.Migration + + def change do + create index(:revisions, [:project_id, :language_id], unique: true) + end +end diff --git a/priv/repo/migrations/20151221054134_add_user_id_to_comments.exs b/priv/repo/migrations/20151221054134_add_user_id_to_comments.exs new file mode 100644 index 00000000..32a04f61 --- /dev/null +++ b/priv/repo/migrations/20151221054134_add_user_id_to_comments.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddUserIdToComments do + use Ecto.Migration + + def change do + alter table(:comments) do + add :user_id, references(:users, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20160112130538_add_comments_count_to_translations.exs b/priv/repo/migrations/20160112130538_add_comments_count_to_translations.exs new file mode 100644 index 00000000..ddf00d7b --- /dev/null +++ b/priv/repo/migrations/20160112130538_add_comments_count_to_translations.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddCommentsCountToTranslations do + use Ecto.Migration + + def change do + alter table(:translations) do + add :comments_count, :integer, default: 0 + end + end +end diff --git a/priv/repo/migrations/20160117013744_create_collaborators.exs b/priv/repo/migrations/20160117013744_create_collaborators.exs new file mode 100644 index 00000000..3bf5eb75 --- /dev/null +++ b/priv/repo/migrations/20160117013744_create_collaborators.exs @@ -0,0 +1,20 @@ +defmodule Accent.Repo.Migrations.CreateCollaborators do + use Ecto.Migration + + def change do + create table(:collaborators, primary_key: false) do + add :id, :uuid, primary_key: true + add :email, :string + add :role, :string + + add :user_id, references(:users, type: :uuid) + add :project_id, references(:projects, type: :uuid) + add :assigner_id, references(:users, type: :uuid) + + timestamps + end + + create index(:collaborators, [:email]) + create index(:collaborators, [:user_id, :project_id]) + end +end diff --git a/priv/repo/migrations/20160217231650_add_path_and_file_comment_to_translations.exs b/priv/repo/migrations/20160217231650_add_path_and_file_comment_to_translations.exs new file mode 100644 index 00000000..20c1c512 --- /dev/null +++ b/priv/repo/migrations/20160217231650_add_path_and_file_comment_to_translations.exs @@ -0,0 +1,10 @@ +defmodule Accent.Repo.Migrations.AddPathAndFileCommentToTranslations do + use Ecto.Migration + + def change do + alter table(:translations) do + add :file_path, :string + add :file_comment, :string + end + end +end diff --git a/priv/repo/migrations/20160217232101_add_path_and_file_comment_to_operations.exs b/priv/repo/migrations/20160217232101_add_path_and_file_comment_to_operations.exs new file mode 100644 index 00000000..4f2b9f38 --- /dev/null +++ b/priv/repo/migrations/20160217232101_add_path_and_file_comment_to_operations.exs @@ -0,0 +1,10 @@ +defmodule Accent.Repo.Migrations.AddPathAndFileCommentToOperations do + use Ecto.Migration + + def change do + alter table(:operations) do + add :file_path, :string + add :file_comment, :string + end + end +end diff --git a/priv/repo/migrations/20160224154853_add_file_index_to_operations_and_translations.exs b/priv/repo/migrations/20160224154853_add_file_index_to_operations_and_translations.exs new file mode 100644 index 00000000..545ddde8 --- /dev/null +++ b/priv/repo/migrations/20160224154853_add_file_index_to_operations_and_translations.exs @@ -0,0 +1,13 @@ +defmodule Accent.Repo.Migrations.AddFileIndexToOperationsAndTranslations do + use Ecto.Migration + + def change do + alter table(:operations) do + add :file_index, :integer + end + + alter table(:translations) do + add :file_index, :integer + end + end +end diff --git a/priv/repo/migrations/20160225005143_add_rollbacked_to_operations.exs b/priv/repo/migrations/20160225005143_add_rollbacked_to_operations.exs new file mode 100644 index 00000000..5fa66e00 --- /dev/null +++ b/priv/repo/migrations/20160225005143_add_rollbacked_to_operations.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddRollbackedToOperations do + use Ecto.Migration + + def change do + alter table(:operations) do + add :rollbacked, :boolean, default: false + end + end +end diff --git a/priv/repo/migrations/20160301185757_add_bot_field_to_user.exs b/priv/repo/migrations/20160301185757_add_bot_field_to_user.exs new file mode 100644 index 00000000..9972f566 --- /dev/null +++ b/priv/repo/migrations/20160301185757_add_bot_field_to_user.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddBotFieldToUser do + use Ecto.Migration + + def change do + alter table(:users) do + add :bot, :boolean, default: false + end + end +end diff --git a/priv/repo/migrations/20160315122640_add_last_synced_at_to_project.exs b/priv/repo/migrations/20160315122640_add_last_synced_at_to_project.exs new file mode 100644 index 00000000..2bde1d20 --- /dev/null +++ b/priv/repo/migrations/20160315122640_add_last_synced_at_to_project.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddLastSyncedAtToProject do + use Ecto.Migration + + def change do + alter table(:projects) do + add :last_synced_at, :utc_datetime + end + end +end diff --git a/priv/repo/migrations/20160317201856_create_documents.exs b/priv/repo/migrations/20160317201856_create_documents.exs new file mode 100644 index 00000000..1389ac92 --- /dev/null +++ b/priv/repo/migrations/20160317201856_create_documents.exs @@ -0,0 +1,29 @@ +defmodule Accent.Repo.Migrations.CreateDocuments do + use Ecto.Migration + + def change do + create table(:documents, primary_key: false) do + add :id, :uuid, primary_key: true + add :path, :string + add :format, :string + + add :project_id, references(:projects, type: :uuid) + + timestamps + end + + create index(:documents, [:path, :format, :project_id], unique: true) + + alter table(:translations) do + remove :file_path + + add :document_id, references(:documents, type: :uuid) + end + + alter table(:operations) do + remove :file_path + + add :document_id, references(:documents, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20160413200143_update_translations_text_to_text_type.exs b/priv/repo/migrations/20160413200143_update_translations_text_to_text_type.exs new file mode 100644 index 00000000..327776f3 --- /dev/null +++ b/priv/repo/migrations/20160413200143_update_translations_text_to_text_type.exs @@ -0,0 +1,23 @@ +defmodule Accent.Repo.Migrations.UpdateTranslationsTextToTextType do + use Ecto.Migration + + def up do + alter table(:translations) do + modify :file_comment, :text + end + + alter table(:operations) do + modify :file_comment, :text + end + end + + def down do + alter table(:translations) do + modify :file_comment, :string + end + + alter table(:operations) do + modify :file_comment, :string + end + end +end diff --git a/priv/repo/migrations/20160414130613_add_value_type_for_translations.exs b/priv/repo/migrations/20160414130613_add_value_type_for_translations.exs new file mode 100644 index 00000000..d3e42c17 --- /dev/null +++ b/priv/repo/migrations/20160414130613_add_value_type_for_translations.exs @@ -0,0 +1,13 @@ +defmodule Accent.Repo.Migrations.AddValueTypeForTranslations do + use Ecto.Migration + + def change do + alter table(:translations) do + add :value_type, :string + end + + alter table(:operations) do + add :value_type, :string + end + end +end diff --git a/priv/repo/migrations/20160508173942_change_length_of_uid_for_auth_provider.exs b/priv/repo/migrations/20160508173942_change_length_of_uid_for_auth_provider.exs new file mode 100644 index 00000000..7a47ad30 --- /dev/null +++ b/priv/repo/migrations/20160508173942_change_length_of_uid_for_auth_provider.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.ChangeLengthOfUidForAuthProvider do + use Ecto.Migration + + def change do + alter table(:auth_providers) do + modify :uid, :string, size: 3000 + end + end +end diff --git a/priv/repo/migrations/20160605190558_add_stats_to_operations.exs b/priv/repo/migrations/20160605190558_add_stats_to_operations.exs new file mode 100644 index 00000000..9a441c40 --- /dev/null +++ b/priv/repo/migrations/20160605190558_add_stats_to_operations.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddMetaToOperations do + use Ecto.Migration + + def change do + alter table(:operations) do + add :stats, {:array, :map}, default: [] + end + end +end diff --git a/priv/repo/migrations/20161006003717_add_lock_file_operations_for_projects.exs b/priv/repo/migrations/20161006003717_add_lock_file_operations_for_projects.exs new file mode 100644 index 00000000..81896e75 --- /dev/null +++ b/priv/repo/migrations/20161006003717_add_lock_file_operations_for_projects.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddLockFileOperationsForProjects do + use Ecto.Migration + + def change do + alter table(:projects) do + add :locked_file_operations, :boolean, default: false + end + end +end diff --git a/priv/repo/migrations/20161206005245_create_translation_comments_subscriptions.exs b/priv/repo/migrations/20161206005245_create_translation_comments_subscriptions.exs new file mode 100644 index 00000000..1fcd3fbd --- /dev/null +++ b/priv/repo/migrations/20161206005245_create_translation_comments_subscriptions.exs @@ -0,0 +1,15 @@ +defmodule Accent.Repo.Migrations.CreateTranslationCommentsSubscriptions do + use Ecto.Migration + + def change do + create table(:translation_comments_subscriptions, primary_key: false) do + add :id, :uuid, primary_key: true + add :user_id, references(:users, type: :uuid) + add :translation_id, references(:translations, type: :uuid) + + timestamps + end + + create index(:translation_comments_subscriptions, [:user_id, :translation_id], unique: true) + end +end diff --git a/priv/repo/migrations/20170415135352_create_integrations.exs b/priv/repo/migrations/20170415135352_create_integrations.exs new file mode 100644 index 00000000..f85bb015 --- /dev/null +++ b/priv/repo/migrations/20170415135352_create_integrations.exs @@ -0,0 +1,18 @@ +defmodule Accent.Repo.Migrations.CreateIntegrations do + use Ecto.Migration + + def change do + create table(:integrations, primary_key: false) do + add :id, :uuid, primary_key: true + add :service, :text, null: false + + add :events, {:array, :string}, null: false, default: [] + add :data, :json, null: false + + add :project_id, references(:projects, type: :uuid), null: false + add :user_id, references(:users, type: :uuid), null: false + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20170822120156_rename_from_operation_to_rollbacked_operation_id.exs b/priv/repo/migrations/20170822120156_rename_from_operation_to_rollbacked_operation_id.exs new file mode 100644 index 00000000..779e0741 --- /dev/null +++ b/priv/repo/migrations/20170822120156_rename_from_operation_to_rollbacked_operation_id.exs @@ -0,0 +1,7 @@ +defmodule Accent.Repo.Migrations.RenameFromOperationToRollbackedOperationId do + use Ecto.Migration + + def change do + rename table(:operations), :from_operation_id, to: :rollbacked_operation_id + end +end diff --git a/priv/repo/migrations/20171105171503_add_top_file_comment_and_header_to_document.exs b/priv/repo/migrations/20171105171503_add_top_file_comment_and_header_to_document.exs new file mode 100644 index 00000000..fb13e4c4 --- /dev/null +++ b/priv/repo/migrations/20171105171503_add_top_file_comment_and_header_to_document.exs @@ -0,0 +1,10 @@ +defmodule Accent.Repo.Migrations.AddTopFileCommentAndHeaderToDocument do + use Ecto.Migration + + def change do + alter table(:documents) do + add :header, :text, default: "" + add :top_of_the_file_comment, :text, default: "" + end + end +end diff --git a/priv/repo/migrations/20180202190429_create_versions.exs b/priv/repo/migrations/20180202190429_create_versions.exs new file mode 100644 index 00000000..45e3d673 --- /dev/null +++ b/priv/repo/migrations/20180202190429_create_versions.exs @@ -0,0 +1,26 @@ +defmodule Accent.Repo.Migrations.CreateVersions do + use Ecto.Migration + + def change do + create table(:versions, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string, null: false + add :tag, :string, null: false + + add :project_id, references(:projects, type: :uuid), null: false + add :user_id, references(:users, type: :uuid), null: false + + timestamps() + end + + create index(:versions, [:tag, :project_id], unique: true) + + alter table(:operations) do + add :version_id, references(:versions, type: :uuid) + end + + alter table(:translations) do + add :version_id, references(:versions, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20180206220517_add_source_translation_id_to_translations.exs b/priv/repo/migrations/20180206220517_add_source_translation_id_to_translations.exs new file mode 100644 index 00000000..47becd58 --- /dev/null +++ b/priv/repo/migrations/20180206220517_add_source_translation_id_to_translations.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddSourceTranslationIdToTranslations do + use Ecto.Migration + + def change do + alter table(:translations) do + add :source_translation_id, references(:translations, type: :uuid) + end + end +end diff --git a/priv/repo/migrations/20180209153309_add_locales_columns_on_languages.exs b/priv/repo/migrations/20180209153309_add_locales_columns_on_languages.exs new file mode 100644 index 00000000..d75d8ad3 --- /dev/null +++ b/priv/repo/migrations/20180209153309_add_locales_columns_on_languages.exs @@ -0,0 +1,16 @@ +defmodule Accent.Repo.Migrations.AddLocalesColumnsOnLanguages do + use Ecto.Migration + + def change do + alter table(:languages) do + add :iso_639_1, :string + add :iso_639_3, :string + add :locale, :string + add :android_code, :string + add :osx_code, :string + add :osx_locale, :string + end + + create index(:languages, [:slug], unique: true) + end +end diff --git a/priv/repo/migrations/20180219005702_add_picture_url_on_users.exs b/priv/repo/migrations/20180219005702_add_picture_url_on_users.exs new file mode 100644 index 00000000..c5b1d032 --- /dev/null +++ b/priv/repo/migrations/20180219005702_add_picture_url_on_users.exs @@ -0,0 +1,9 @@ +defmodule Accent.Repo.Migrations.AddPictureUrlOnUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :picture_url, :text + end + end +end diff --git a/priv/repo/migrations/20180322165819_drop_auth_application.exs b/priv/repo/migrations/20180322165819_drop_auth_application.exs new file mode 100644 index 00000000..c83d576c --- /dev/null +++ b/priv/repo/migrations/20180322165819_drop_auth_application.exs @@ -0,0 +1,11 @@ +defmodule Accent.Repo.Migrations.DropAuthApplication do + use Ecto.Migration + + def change do + alter table(:auth_access_tokens) do + remove :auth_application_id + end + + drop table(:auth_applications) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 00000000..ca2a9385 --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,12 @@ +timestamps = %{ + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() +} + +languages = + "priv/repo/languages.json" + |> File.read!() + |> Poison.decode!(keys: :atoms!) + |> Enum.map(&Map.merge(&1, timestamps)) + +Accent.Repo.insert_all(Accent.Language, languages, on_conflict: :nothing, conflict_target: :slug) diff --git a/priv/scripts/ci-check.sh b/priv/scripts/ci-check.sh new file mode 100755 index 00000000..f7faaf43 --- /dev/null +++ b/priv/scripts/ci-check.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh + +error_status=0 +success_emoji=✅ +fail_emoji=❌ + +run() { + eval "${@}" + last_exit_status=${?} + + if [ ${last_exit_status} -ne 0 ]; then + echo "Something went wrong. Program exited with ${last_exit_status}" + error_status=${last_exit_status} + else + echo ${success_emoji} + fi +} + +separator() { + index=0 + + while [ $index -le "${1}" ]; do + printf "–" + index=$((index + 1)) + done + + echo "" +} + +header() { + chrlen=$((${#1} + 10)) + separator ${chrlen} + echo " $1 " + separator ${chrlen} +} + +header "API tests…" +run mix test + +header "Compilation without warnings…" +run mix compile --warnings-as-errors --force + +header "API code auto-formatting…" +run mix format --dry-run --check-formatted + +header "API code lint…" +run mix credo --strict + +header "Webapp code auto-formatting…" +run npm --prefix webapp run prettier-check + +header "Webapp code lint…" +run npm --prefix webapp run lint + +if [ ${error_status} -ne 0 ]; then + header "${fail_emoji}   Something went wrong. Please fix it before committing." +else + header "${success_emoji}   Everything looks good!" +fi diff --git a/priv/static/images/accent.png b/priv/static/images/accent.png new file mode 100644 index 0000000000000000000000000000000000000000..f8306f4c49855658cbaa9857c0241b83f0822f27 GIT binary patch literal 12106 zcmX|nbyyYA_V&=7lF}X0NP~1qcZj5vbf<76q+7ZhX^@sUw7>yGX^=*ckVDC#zwzGt z{r;HenZ0Mvn!RW36??sJjJBpS9`;jg008h*RTOjq0BPgjg^3RKyqoP!2OE)&^77hV zy2^6&s*3W${9?kw+yXrO0KlH&lhUasFH0EJ#~f?TNLJCPt-?S?tXQgY^jualnXn|x zD6QK}E^#yni!p<&jYHSpvNSrO)ptvp9y81|#_|~%wlL%Ll7b4M;KZMh+m-C4pC*mG z_ARwb&rz4m`lHn`BqyHL6lB=vczlm3EU2(`HjJmv@%@Ch}x zVrw~c=@1qW;k%G|4+jmna?>Q>6ygNobr!)(zuOpu801wqzTWkwic2YKe6^TXy=_e6 zV?AF+w44tjHLR7FeZ63E@ue1qu@PGCKjU^ zm+z4AKTY^_k`?lXKWbd)Y8*<1CDL?sR6925b#Tcpq|pkMkd86@EKnb~rEF=C+2d}h zGdTSFWqJ&S%XRP$?}$#O5aCz*2#Ei+*X>`W#{>0(<*s7l1pqit{#{5wP96oQB`QaK zV{c;(b#WUv7aprOZq~Lu{x0s|XaJCu_IJ0kakBNMx3;x+bd_Q}`O?Qo@AyWF(MU*x zPs3f_*1=IF(9>2gP*dL~(8)&Z4Wsl6Y)OA{P=JfAw-vp=i?gejxW5$Rf8>gT_y4+i z8R`Es#M?=V(O5&9Uf#{qmR^WQh=-5y1vb5;=Nmh5T?M88y$rUb7#+O5-Nku%{rvoR z`~-R2Jneb;#l*yT`2=_c1h~Nw++G2$-d6tHu3k+4MEqBVf~}X0r=z>Kqnj)JKbcn6 zZa&^pjEubhKEnIIuYl9#{lEEwjsIJ*tt+@pPjEd?U)CG|0GEQQf~>y(^6}4rdU0+Kv}}<`h|G7 zc#4$t;dC@<)e36O*_`+C)EpX)N*R6yUIF~%4mm6BC-2rj2NzarO{$O3=XtH2m!7;U z^kO86EorG5|5@{6D4-*IGvRottv9GtLx4r-o+8e-h;=_#vfH6POqfil_1a$2z4PN2JGtx<_VEFz z%R!%hi~iAtg&ZU1f!0vVp32Z zr%&;US^N`%xKLsl(c#K1hR}rPdojiAv2Ts{eTmP9bAyxO0z*RYD*AlIPnmT{6o<*X zj)yh2Xu?oc&l3A8md_)=&V1^wgYM14;S(_gGRF&wovQ~^r-?LWosTueHB*EIyL~EN zAKhi3vpFlr4gKB)DzPQb1V33HJR(+xiR6GB(X9s6R^eQDnJ$T~9^99-W@yj(@3+;x z2h+)&v2ynD*>6%t>SxlwY;2-pd~cl)W(5whCF+=K1=e+lFg)rE#DK*Qsel2F*zerl zKZf2kWllcLSUGICd|nV7TQY!a?t^|VdFm%@C#qC;-I=GVp`-t;e?_*IC~7f!vqS#j z^SAf;?@Oxm(cKFC^K2O2n_kpzF>Nf=)eYyUX z#Xih?d0~Be2D+PRZ5v;b*pjXL7{8AQ2p)+F_9a)s4x4ps$pQA7)s~d7Iz7wZ^O@(0 zI+Y(zWCH!)%LK*LEC#TTA8&A9xiMMmp$>>0G{@&hm#ku&&uO-#;Sqit&|J=BAAi_z z;THCKd}Iv^&Q<<)@;uB)Np) z+mn~F3l6qt`e!HL@-6dRa(yn<`AFHnqU%hkyx0vqqdqPCO?Ko?QhqWzDL9nrYz#^7 zy(sj+KPzHS7--M+&&R`d4-1_C(sZt)-_f7TN)~gn_z|eAvJWXpN7-rni| z5f5F4de(dl=aNCn%+3`)WP+_Jy**+)Ac9s6r;f)?NaN+a~= z1&&@t#7WYSo@5Kabyl{#vbf2SKPd9LdvFjND(6-sm6%`foj>^V5SwHfz*X_LgUdJg zWhMNW7yry1*&VWc$hmmUK{Mc&ijby#3E*G1FW;&FoV6P+%w(tZ$nIIoC{!t!`rkIc zWJD1FhU?0hda{uX2d6)W1Zz%T+ZyxTbri1C>&twZRN6{Dn2LW=8Agvm^>Qb5hQ<{&x|>j40mDpd(MX>({rpB z#I7pGLfl(7{0JvRk{NiR9mEkMH+RKmF+dKaJ{h=URv*64=cSWCk@1vH{3T&kJq33e z$1wtuo5?@>OXO4w1y7gHVFJ2qB?^UnKDvkskFV$es;@Byo1Kny-tiqO0UCfB=*(iT zEJ53QV93N{2tYPI;ZS!dj%~tsU&;cQ1V)tNPoZ(fcK~6RB6I4iO^Fyq0$@2yMWB>}MHPQ~KLf%vQJuk=eW%ym|4-Z(O#_B3O8MOE}3#UCY(6sYx*#;Wu~ zXxgSALs~16y#qhE6(BOuuz`8qpkSVuN+U!YVW<^o443%P=c+HH28{B(Tf-HxfGPi& zwB`%U4N@*8z5(x2qKA6D1o$t4sSG>^iGU5n zq-ui7$ObavB^eC>6-ju5%NqP8*3B5qd)5~i(<0Pe5EU`+xP$>H$C?49tZoGl#Hjfz z0N6K%m8okWGU7_P_y8znRs6=o26L2~*Qp2@Ik3U?P%RL?qizmJkCz|=L%)eq1;sWc zvW8KCs!CaAhrxk%N0xSKB;Y{nXU^Jw*7^>aP+=;4*0G@}+P{N-Q|&_TH2W zACTdsA?s0Dd+heV6o^9x2$Nrzoty%+{nh}0>fCX-8I*{~iUa`6MP<%#GyIKfE@VJP za0iFP!EpGk9lUd!CXOlx+z>D%Pr7PNyudqkdRwdS zgue*@CXd?>DlM-EDWop#dsxO;D8o@ttIu{h}@(lvW1z9+!zcMNmT7W@FXDU71SYt-r8?QEwT;DKB@ z8?BT%p(Bk6$`h%y<_lEHilL@^X<)v?^W9v`h#+Mv&}0% zkq2Do?(t2@c<$s-Kwo8xZoIi~`+4A#_nf}7I0)T^m;21q@P792ar||>RB{J*MWmau zM^+3<;^E_2zWG(o>DXh82>!8Bx<88+w-qg9D416~iv;SF^?ak_4#VwY8TSvBKfK_A z#>0_-_vB#-hLN20MFVTyZj0~<`krcyc6)%0Jl>k#C-PQ=%F`;5bjL|`%#BWUGBcxx z?=Ks+hhEkVTt&na4R>?n&8|;&MY-i5FrJ_-th>8Pv1?7h6I@>D-Vega3ETT>;(v%i z*aTJGP1bnp&rM0}9cg--0@wKy0#LR@RQG_j3Do{2czR~u6z~39bwt5F)zs_h2@{#T zweLc~LHuIw27hi$enq;$nljJ`e!?V?Pk(>10UB(rH{KxXbARU-y4fK{+Birbt3O<$ zPW^fDbKuI%BRK|X)2aRfNzDAWpjx(3k*h@&><0&gji19kp^KI;KCMnSFnIS{m;(;! zInJzOi6&BaF4lXsmj&hrt=0Q328e=hA?n#iO^5a{L3g``-^9JIvvE5Ve)g>R8W*Q; z1`VM7Kp#^zhY4v%sna0)lRiN*x(M}2U-0lupW6%y>yiu;!YEWQ`Q`2 zn=|us#zxomC)^DYO3TG6L2hL{>3UCf%29&>AGb2j;M}-DT)tW-F;le(oo4TB9}XvM z9zDt>Nm3-q#QsPhZe^{Sj8fVNd*Vw|vGi87%mfWZKpF-e=*lQczx--<<1AOkrWrA{A+5@l(P z#6r0a@#~k;YT+)dz4kIz;V-UU%d>y*Fx%}}XnfcpPS4(BVS*?NW)>2*^=fQ#$3KZ*}7wp{1;yQb-pLsT;+wib9-l7w>P&51ofR*#aOphR@M1E2Y*YWSm72ju0q{1tO z4hZ48of}z->YYz4<`cxGiQWr#D!oR%_rCL7&gB9vj*Li>$^GF?+{!i%VgibzV+Pbl zSh)r)#85R#(X6UlInzY-A1;3==6Rf3nNxtZF=&5BMyc{IB?~yiEUskzSpj9O)uMy) zyTSG*MC|);)mguoI^J;>nEE@xuEty=#A5KgfOBbc3;)$)SO(MnJ2P9`HlrJAaDRv| zU1Du4;ZnrE73+%1U~)>Vp*GmM487Kz7jhQ(@&c(l8wrJ58S}VfoN~~3rE#p{1{MAS z9k-%|`V-0n7WeH%_p#@^sPn1oJ9Fp_ISM+HY2w?FCx&eY&qfU%t}bi67B_M`%!g7# z+0**Nu&`-+von}hl4?FSklozd5gpP9?-OeQNmfNN6GSay^=DhX=GSWA;nDbmhz#b=Wc9ZDBV08Cmac zQ+;#gt8_!EY#1$QbUc4eFAg=oZXAKXDrcM1Dx$$)ex3~a8yCOjtypT16&A%%DQM$j z^`0!#`0C%P+W;PG%c8hiv=-{(5OMXYosO4TMU8Dh0Te0|mr)w!ni$fqTvgQDwI&*C zkJ&|K@e1I5V@VhYx}?A~%?YBX^g(l40U1S~bC!`2vgqi=?bNRo!ES#uTUnz9cuJ?F z*0(v7Rx~&d_^ge(u3{H+Y^FNYt>GfOT+-;<%oO``C;x)krPvsDYu8g2l}?rNURcVRSe# zI_mpYYM0X8eRXk-o+@*Z+Y8Vl8m`8lA=vw91>7jcyrLVq6Y(~Da50e`#R3&g6P5ll zVQS8VIGcoPy6JA`yjw7sdG(%RS~RidW70(cEm28p6H<3Nk}Mx+<5-Zz6Bgd_%d?Pz zZk*jY<@ugaU)zgBvyt{c+njUdbdqV9V;PIUT0ew-?o4F5c$d9Gi1qx2$b2|aLRVlO zjFoAb?fzqRQ`?-+0t3pmyv$hSnxupwSAz5^0TVM2YT=N_O7qLi6#9|ZmwjjV?lhwY z_`GMFzMf_*$nCts5CT4W6~~E`mhW)AlT&g?*cyCJYw4iC?8UpP{F}0(Gob<<)PuA| z;KdMx-e7h@_M+&QqSigO7cDkj?n!G;OZ2xo#jD6C{)R+l4Vn>dPs88q|Grvz)k8LH zqUrXhy3!~!Nz8zt_JGb+=c6LL;Ow`;pPMDg&zQP7*c;~*m}S+w?1P&bOml~;dux<| zf2}7jlW5osiMuOljjk2&rg_57o{&Q8z=3k@0e{-4KYekSd(X67sM2N^c;RA+jF9I2 zn%>hGwnr}O@Cmtt2IYNOJGF~v`)<`CUTKN+J=OV#E>z7uFqlBu@D(YITxak4jVU=H z5`u&(2Ol0va=Dec)q(;8uBL@=cs2fI0TFW^=?e)PCQ8+>tB^Y+K!I)4uQ$ynbiT}# z8&V$>O=V2wNb>|TL>w}#-X=OqPOFr2kwV{5V1D!b*x@RY)b{5?epr;7@5A!ozDGJk zdt(1vF-`~$B_?zE?!HJ|-di!~o`vaODTGy1&0ocjs4bDr#`OSVSrQGL=bLhi_W zFQaRkxHD;CBy4y2!)hkuRo=D!;I8?fwoDRGYr4hiXmA;HWIR`~9Ep+Q8kC8i<~<|P ztKUi^aBF*cS8vsex^xu+czh8_yvzdR@p&J(1tN9nsHd+tXS0=YP7bf4}(Sv^ksJEj} zuF}-wZGKeDdss5xWb(>Bp|I_H^#@GRp*`E2yI)3j2g{s zB`b>-kKJ0zH=U1WBM^jXVg+tXbNlDfx+mG|Bs!VmExsGNQEh|R-LW)&e0x<%)b&gX zX0vn7^Y=Tt-v=jF+_}$l4+c$V%kh64^_A$}qor?i>JZEvVuBm93XD+;aU~*;i!CN? zogHp1>9N;%nl1LC>wgM1YhR7<7k< zG0nicKieHIhgjs%+@GN%+dGSOjn!wAPW_1Tgvc%Oo=f$u9`g0}&5as#c^a@hEi3lY58!$Z(fU!FFQo6dO7 zy6UT5*wX!)dA&$rO*iV&ZF|@GO2;8Zhj|n6>-)1B8hJ`@Uij3OiWja_Z*xYG`R}f{ zfhA05Ff4)YoLiZ-J)$bQ^IWwt2>_``4=39cEgDb{p)UW zS3GA+Ec_ZyLM^7vOsl~FYM5V;F672_9y{EGB$`?}Kpla||fNPE1PkDh@uu*XM_ z4>~Yihn+c(w?@~hlfleN)#N9G0Nw`pOaP4Tz}Y~cGhOqtsm46`*LSSY z*gVrSx(T8$Y)JA<6HX?(~b(^(Nm6 zre+S!R*bI}zgWkqInmd^+Z2{R6RZCoLNH4P#i68#dzDyF#a|7cuNFOp8K3X-=I-tJ z3AGI;ZgbWd>b(NAEG@ZQ#h%(}PUwIYhnbxB=o9ObY&Q>(rp#ErsJ5qIu0+nym(fQ3 z(-It#xs>^RJ8tJM4gs&QK*Ze7udw2DY$u!zZ}gEVy>Rs<_pS;4a2RE(#!tL=n_5v> zS9UGIw)zOQwTE`?nq_3;OXSeO4dYqQ3kiAC#2Y)M=`y>ilM!UxR0g?Sm5 zcMpFALTuy^Nw~=@JGz0F0#ScMCo)k;JQt?k^b*3@tr{R^wi+bAwPm4E@p+@uUGI(4w zpP7G^#T#+n)3{pZ(@z=>6R+2OA!q+5zP+~|ERX?9-#qn;u6`SapOr{Q@>yWsI_3O{ zW6kpFYwUhjJmmon6z-`9c?ETUe~xq}=^^XczEY%VZrp7Sc#PZlm*(dY+ZUB`+A#T~ z-=WY6YO}UcbEjo6z0c2pRH`LG>1m=m8k0_KY*UcLX#eaws=%rU*I6tT zLw8}45zLA5TyW$I$_>otxwqpnH|xE4YvDKcK*~V#c7SmEHUM#Q_0IpmQ z!4A;JFAU)WuW8%{#gtxfr&ASXvAOh6#kB9xt-&C_)qWT^dNf<$` zVaqNV?>kF67x|9SR8?47f$$s~#9q+yrj6PstH@|VN8%b$a}-Gm!&}|&3=N4~2yyOg z8#Sm#3CLnQhSh6Fw zg5M$M-Ce7X?8SqbORrZ~po%w>wd+Rp(Ujr$x<^m2X=&#@l0(Wgk7l$(sZ%nSkdr>)elCGyCnfCP(p zXFrpe5O9Kh?1Iu(uToV2f;St_9mTpr+gK}K`RwjX6eXTSet~uAe<3uaOB0kXu71f3 znVXAea zbm0rlEnqGEtJyD<=rnP>dILqbx-QbJOzop2X45kZ^HJc#qNn#O1h?{U6i)|7fcX02 zAiLZgOt`qsM@kI%o;y(ygZ|sn81&x}5B!F?4A(On5!V3miFt?RGQBCs3X=G6?QOC@`;%2!CL*PNej!w#T@RzB}h>%xkis2o+sc-&PgL^#+LR!4bQt4pj(LxZ__AJ}R!X2FDpG9*Q#O>T#y>{b zz9l%;ws>{fdG&O}lz=LY2wF<{mGGgB%cv0Tp2i*YY2(U0H0Ffu_f?53&>;5(u#n?x;j3d;T@ZH+}v7WfwQ?&TH7y5PuJd~$f%7$m|?@V3Mr=vIM?o6jz=t5@q7QKQT8u!(ta(bVND!u*pp37Ya3*!OYJ)Y|A+HwYR&qe{eh;^bkRB`^G;E`L&co#9H-lFmf3GL>&c!&7xhuy&tKNFJ#RDW z_~%nkp9sZMS;8hv1VjDBo(0nEe{iBCzyB2POslFm#(T$#;%wgFegl1B-sjRTU=Fd0 z-Px`Em(9~uiLFJKRqv1&P6N4f&tM5k0Abd@+MOnDqgcFJ0ETD?MrnZ730g1X`m6cd&tj}KpB+9tY{#7w%c|7A+2LPx}=^*X~>y{TQ z60ngqtzgOwOI?R@vLb`6FN{{1cdT6kL;x`QM#-Cf4rBNY08pQrDrBav!{3M^L+Yy+KarQ`Z;GSup_1Pwk9d z8F#-}1!Vz1P2;;5g?AE^;QD_kkKJw{c_B$EnE~XmzdH$Aw*m2*oE5i8t-fv|JYZvT4<3rWHnD>Y zJipBFk-i^^fXlE_07ajwqbFHHp;GDsfQ;Z5rnbyGhKXzk0Ae@J$n)_|)=yvw05p!G zJ9N`W-Cl~1m!SZ170v7-Te@R({!g9|0GLZ7(M~L(`}13bX<#d6yl9??T^%Rgn`NXBl zWO7J8fOdh70z8$mRY8i$F~(dUL`DXf9q?3h`~eZt(0@4L!EeYoVQPyNgJf3Z{~$$A zIfM?`6M$YbDLHQpXXDpa0~aR%HL0p~(DY;#iI^DH0ddKq#P)&-71(S9{3RI$uz_os z{PB}ikquoOF5n*PC+5y{$&km-dqkFgvfyR+nZ{657VVIk~TV7<`0L6)5ifnT>=>X{#-#=*ivWJ~* z28O$7!e#ki5c-I)H&+oC7%|ZZ%cg@Kl-07C!2ZG3dET~Kn2##mgC*#|aZlY}=hTUM zLQN3$?)X2{8+Vh-BUupy#dU(b$W(1ZgTD8f9Q}XD+%*EuOeP69*f5Um7-I5T&!EWc zlclKqL33yFpo^mens9O2Ju3hLm7J2LnvEgUd@_Be z4akg3mHstLjf%zF^cvEn#RpUDz@d>~w%}#6_9fQl{KLQ|5;A0Huw5udj^!qek4_my#+pu| zP%Bj%?_M*D{4-D#!fSS#&zs)TUh1fW4Eem$zcQuYERQh5KLjA3X`5vWdDBw~HMbXE z0#$Q{njIy&Gy@6GI-y8_wabpPJ!ci+FHU(tH1QeQCv7=@A_6)fikmU`6JNN0Dnf*S z5s0dia%04j`ArCFg`R)~z^Lk}56dt7&JppCzLEDI`iAQf;$tF19?#k)1Da)V`yB14 zrq*xj0cm2bvqt2`ufnbEO@n%@5se3=y}40j5H!Od+iKYqLxRIrw3Yz6ZqS0dujlxM zNSXAu6}ly1t9|(0l9j7TgicTiFE$)0Ng@at)_!6f9q{v_b zs4B-|6TeT&F}Hr!)C^TRV*|)Ve{+zIx=DP_l)tYmQUhfO71bA>6;-Bk9VuC?y>_KQ zvVz5JkQ$S(X>7GbwBYa;=chob%rQ&$Q7s8+C}w3$`W8DCCIprG(&9|rM!CFdD6GTm zE@2W?M(Qs8a~=n$7{jWWUmgV6Of>Vx$B;}5THG<_Qdo0}0r&0IzU1$>2irGX% zJwrUgyif-TMJ?awu>yk8QE^F&UxCspdtXrr3dL-?fxYN}EkY`)BB9IpDN>EaPWJJ7 zdRJ~?kA9S*-C-=Mx&M;3Fgywt`ztUUWToOdkJL$a4GA-2emX;B*v<8(=_QC1J86at zwz!2A=m`%@YOME)*$)&z3zGa0d$maa{^KI=2lr%jMI9fsQx%>-AT`ix05dGJq{=_M zqltp;#Tyhf;2(@SWU^AQ3XCj4igi4|qNjchxW(E;py=*o^^MG$It_xfLXhA^(N69)w1SGBy5kb6 z(YlQVk}6|)<0WFQx9}c=#0OAMLn?1S{D01(5LbL|T+Vc&R0+3utpPL&6@;nEy-uSRy+FF;2%7~gALZnCE)=%5){)c4w zmGVJx-)T6cS6cXQ(CYmIb@l)Fm)YcBH5Aj08GWN0Fh!4pcc&+bL+f*z!F87$;p~hs zD#OTfhrVo4$?@^6BW!xd3Q;a;Tx|?lLEh?@;?-g4ME7ie)8HYF*LW{n4wyo@GOuk` zA{)!W*=%9iU7q@+WbuHs(;+%U%ymnl!{bKMWMF7*EP?{$jK;oHyWz=9`EhaA6ZYhnifMp`^AN#BnAGs zN`qM7-da4acdkE5IiH4V#oe!Oi#}fE9Y0cH8iVhRAAB=h5PDE(xp?)7hs5Lo#|Etg zMle!rT~_rs;o|W#=VKuU{87T>p&@1SF-Ll{ODgtz {:ok, mock_response(200, %{"email" => "test@example.com", "name" => "Test"})} end] + + with_mock HTTPoison.Base, response do + expected_user = %User{email: "test@example.com", fullname: "Test", picture_url: nil, provider: "google", uid: "test@example.com"} + assert Google.fetch("test") == {:ok, expected_user} + end + end + + test "valid with picture" do + response = [request: fn _, _, _, _, _, _, _, _, _ -> {:ok, mock_response(200, %{"email" => "test@example.com", "name" => "Test", "picture" => "test.jpg"})} end] + + with_mock HTTPoison.Base, response do + expected_user = %User{email: "test@example.com", fullname: "Test", picture_url: "test.jpg", provider: "google", uid: "test@example.com"} + assert Google.fetch("test") == {:ok, expected_user} + end + end + + test "valid with uppercase email" do + response = [request: fn _, _, _, _, _, _, _, _, _ -> {:ok, mock_response(200, %{"email" => "TEST@example.com", "name" => "Test"})} end] + + with_mock HTTPoison.Base, response do + expected_user = %User{email: "test@example.com", fullname: "Test", picture_url: nil, provider: "google", uid: "test@example.com"} + assert Google.fetch("test") == {:ok, expected_user} + end + end + + test "invalid with status" do + response = [request: fn _, _, _, _, _, _, _, _, _ -> {:ok, mock_response(400, %{})} end] + + with_mock HTTPoison.Base, response do + assert Google.fetch("test") == {:error, "invalid token"} + end + end + + test "error" do + response = [request: fn _, _, _, _, _, _, _, _, _ -> {:error, %HTTPoison.Error{reason: "no internet"}} end] + + with_mock HTTPoison.Base, response do + assert Google.fetch("test") == {:error, "no internet"} + end + end +end diff --git a/test/auth/user_remote/authenticator_test.exs b/test/auth/user_remote/authenticator_test.exs new file mode 100644 index 00000000..e251bd46 --- /dev/null +++ b/test/auth/user_remote/authenticator_test.exs @@ -0,0 +1,51 @@ +defmodule AccentTest.UserRemote.Authenticator do + use Accent.RepoCase + + alias Accent.UserRemote.Authenticator + + alias Accent.{ + Repo, + Collaborator, + Language, + Project, + User + } + + test "grant token new user" do + {:ok, user, token} = Authenticator.authenticate("dummy", "test@example.com") + + assert user.email == "test@example.com" + assert token.user_id == user.id + end + + test "grant token existing user" do + {:ok, user, _token} = Authenticator.authenticate("dummy", "test@example.com") + {:ok, existing_user, _token} = Authenticator.authenticate("dummy", "test@example.com") + + assert user == existing_user + end + + test "normalize collaborators with email" do + assigner = %User{email: "foo@example.com"} |> Repo.insert!() + language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project", language_id: language.id} |> Repo.insert!() + collaborator = %Collaborator{project_id: project.id, role: "admin", assigner_id: assigner.id, email: "test@example.com"} |> Repo.insert!() + + {:ok, user, _token} = Authenticator.authenticate("dummy", "test@example.com") + updated_collaborator = Repo.get(Collaborator, collaborator.id) + + assert updated_collaborator.user_id == user.id + end + + test "normalize collaborators with uppercased email" do + assigner = %User{email: "foo@example.com"} |> Repo.insert!() + language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project", language_id: language.id} |> Repo.insert!() + collaborator = %Collaborator{project_id: project.id, role: "admin", assigner_id: assigner.id, email: "test@example.com"} |> Repo.insert!() + + {:ok, user, _token} = Authenticator.authenticate("dummy", "TeSt@eXamPle.com") + updated_collaborator = Repo.get(Collaborator, collaborator.id) + + assert updated_collaborator.user_id == user.id + end +end diff --git a/test/auth/user_remote/fetcher_test.exs b/test/auth/user_remote/fetcher_test.exs new file mode 100644 index 00000000..2e65f3ad --- /dev/null +++ b/test/auth/user_remote/fetcher_test.exs @@ -0,0 +1,38 @@ +defmodule AccentTest.UserRemote.Fetcher do + use Accent.RepoCase, async: false + + import Mock + + alias Accent.UserRemote.Fetcher + alias Accent.UserRemote.Adapter.User + + defp mock_response(status, body) do + %HTTPoison.Response{status_code: status, body: body} + end + + test "google" do + response = [request: fn _, _, _, _, _, _, _, _, _ -> {:ok, mock_response(200, %{"email" => "test@example.com", "name" => "Test"})} end] + + with_mock HTTPoison.Base, response do + expected_user = %User{email: "test@example.com", fullname: "Test", picture_url: nil, provider: "google", uid: "test@example.com"} + assert Fetcher.fetch("google", "test") == {:ok, expected_user} + end + end + + test "dummy" do + expected_user = %User{email: "test@example.com", provider: "dummy", uid: "test@example.com"} + assert Fetcher.fetch("dummy", "test@example.com") == {:ok, expected_user} + end + + test "nil token" do + assert Fetcher.fetch("dummy", nil) == {:error, %{value: "empty"}} + end + + test "empty token" do + assert Fetcher.fetch("dummy", "") == {:error, %{value: "empty"}} + end + + test "unknown provider" do + assert Fetcher.fetch("foo", "test") == {:error, %{provider: "unknown"}} + end +end diff --git a/test/auth/user_remote/persister_test.exs b/test/auth/user_remote/persister_test.exs new file mode 100644 index 00000000..7ba5de69 --- /dev/null +++ b/test/auth/user_remote/persister_test.exs @@ -0,0 +1,38 @@ +defmodule AccentTest.UserRemote.Persister do + use Accent.RepoCase + + alias Accent.Repo + alias Accent.User + alias Accent.AuthProvider + alias Accent.UserRemote.Persister + alias Accent.UserRemote.Adapter.User, as: UserFromFetcher + + @user %UserFromFetcher{email: "test@test.com", provider: "google", uid: "1234"} + + test "persist with new user" do + {:ok, user, provider} = Persister.persist(@user) + + assert user === Repo.get_by!(User, email: "test@test.com") + assert provider === Repo.get_by!(AuthProvider, uid: "1234", name: "google", user_id: user.id) + end + + test "persist with existing user existing provider" do + existing_user = Repo.insert!(%User{email: @user.email}) + existing_provider = Repo.insert!(%AuthProvider{name: @user.provider, uid: @user.uid}) + + {:ok, user, provider} = Persister.persist(@user) + + assert user === existing_user + assert provider === existing_provider + end + + test "persist with existing user new provider" do + existing_user = Repo.insert!(%User{email: @user.email}) + Repo.insert!(%AuthProvider{name: "dummy", uid: @user.email}) + + {:ok, user, provider} = Persister.persist(@user) + + assert user === existing_user + assert provider === Repo.get_by!(AuthProvider, uid: "1234", name: "google", user_id: user.id) + end +end diff --git a/test/auth/user_remote/token_giver_test.exs b/test/auth/user_remote/token_giver_test.exs new file mode 100644 index 00000000..c2af44ba --- /dev/null +++ b/test/auth/user_remote/token_giver_test.exs @@ -0,0 +1,32 @@ +defmodule AccentTest.UserRemote.TokenGiver do + use Accent.RepoCase + + alias Accent.Repo + alias Accent.User + alias Accent.AccessToken + alias Accent.UserRemote.TokenGiver + + @user %User{email: "test@test.com"} + @token %AccessToken{revoked_at: nil, token: "1234"} + + test "revoke existing token" do + user = Repo.insert!(@user) + token = Repo.insert!(Map.merge(@token, %{user_id: user.id})) + + TokenGiver.grant_token(user) + + revoked_token = Repo.get_by!(AccessToken, token: token.token) + + assert revoked_token.revoked_at !== nil + end + + test "create token" do + user = Repo.insert!(@user) + + TokenGiver.grant_token(user) + + new_token = Repo.get_by!(AccessToken, user_id: user.id) + + assert new_token !== nil + end +end diff --git a/test/badge_generator_test.exs b/test/badge_generator_test.exs new file mode 100644 index 00000000..f9fad569 --- /dev/null +++ b/test/badge_generator_test.exs @@ -0,0 +1,119 @@ +defmodule AccentTest.BadgeGenerator do + use Accent.RepoCase, async: false + + import Mock + + alias Accent.{ + BadgeGenerator, + Repo, + Language, + Project, + Document, + Translation, + Revision + } + + setup do + french_language = %Language{name: "french", slug: Ecto.UUID.generate()} |> Repo.insert!() + project = %Project{name: "My project", language_id: french_language.id} |> Repo.insert!() + revision = %Revision{language_id: french_language.id, master: true, project_id: project.id} |> Repo.insert!() + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + + {:ok, [project: Repo.preload(project, :revisions), revision: revision, document: document]} + end + + test "percentage_reviewed error", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "a", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "b", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "c", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :percentage_reviewed_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-33.33%25-d84444.svg", [], recv_timeout: 20_000)) + end + end + + test "percentage_reviewed warning", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "a", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "b", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "c", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "d", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :percentage_reviewed_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-75%25-e4b600.svg", [], recv_timeout: 20_000)) + end + end + + test "percentage_reviewed success", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "a", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "b", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "c", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "d", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :percentage_reviewed_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-100%25-45c86f.svg", [], recv_timeout: 20_000)) + end + end + + test "translations_count", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "a", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "b", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "c", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "d", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :translations_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-4%20strings-aaaaaa.svg", [], recv_timeout: 20_000)) + end + end + + test "conflicts_count", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "c", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "d", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :conflicts_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-2%20conflicts-aaaaaa.svg", [], recv_timeout: 20_000)) + end + end + + test "reviewed_count", %{project: project, revision: revision, document: document} do + %Translation{revision_id: revision.id, key: "c", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "d", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :reviewed_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-2%20reviewed-aaaaaa.svg", [], recv_timeout: 20_000)) + end + end + + test "zero translations", %{project: project} do + response = [get: fn _url, _, _ -> {:ok, %{body: ""}} end] + + with_mock HTTPoison, response do + {:ok, _} = BadgeGenerator.generate(project, :percentage_reviewed_count) + + assert called(HTTPoison.get("https://img.shields.io/badge/accent-0%25-d84444.svg", [], recv_timeout: 20_000)) + end + end +end diff --git a/test/emails/create_comment_email_test.exs b/test/emails/create_comment_email_test.exs new file mode 100644 index 00000000..f8db20c1 --- /dev/null +++ b/test/emails/create_comment_email_test.exs @@ -0,0 +1,32 @@ +defmodule AccentTest.CreateCommentEmail do + use ExUnit.Case + use Bamboo.Test + + test "create" do + project = %Accent.Project{id: "ec44dc76-9c55-4f64-bb9b-4cf5ff0123ac", name: "My project name"} + revision = %Accent.Revision{id: "ec44dc76-9c55-4f64-bb9b-4cf5ff0123ab", project: project} + translation = %Accent.Translation{id: "dc44dc76-9c55-4f64-bb9b-4cf5ff0123ab", key: "My key", corrected_text: "FOO", revision: revision} + user = %Accent.User{email: "test@test.com"} + comment = %Accent.Comment{text: "This is a comment", translation: translation, user: user} + emails = ["new@test.com", "foo@bar.test"] + + email = Accent.CreateCommentEmail.create(emails, comment) + + assert email.to == emails + assert email.from == {"Accent", "accent-test@example.com"} + assert email.subject == ~s(Accent – New comment on "#{project.name}") + assert email.headers == %{"X-SMTPAPI" => ~s({"category": ["test", "accent-api-test"]})} + + assert email.html_body =~ user.email + assert email.html_body =~ comment.text + assert email.html_body =~ "The Accent Team" + assert email.html_body =~ ~s(href="http://example.com">http://example.com) + assert email.html_body =~ ~s(href="http://example.com/app/projects/#{project.id}/translations/#{translation.id}/conversation">here) + + assert email.text_body =~ user.email + assert email.text_body =~ comment.text + assert email.text_body =~ "The Accent Team" + assert email.text_body =~ "(http://example.com)" + assert email.text_body =~ "(http://example.com/app/projects/#{project.id}/translations/#{translation.id}/conversation)" + end +end diff --git a/test/emails/project_invite_email_test.exs b/test/emails/project_invite_email_test.exs new file mode 100644 index 00000000..0d4ff03e --- /dev/null +++ b/test/emails/project_invite_email_test.exs @@ -0,0 +1,29 @@ +defmodule AccentTest.ProjectInviteEmail do + use ExUnit.Case + use Bamboo.Test + + test "create" do + user = %Accent.User{email: "test@test.com"} + project = %Accent.Project{name: "test"} + email_address = "new@test.com" + + email = Accent.ProjectInviteEmail.create(email_address, user, project) + + assert email.to == email_address + assert email.from == {"Accent", "accent-test@example.com"} + assert email.subject == ~s(Accent – Invitation to collaborate on "#{project.name}") + assert email.headers == %{"X-SMTPAPI" => ~s({"category": ["test", "accent-api-test"]})} + + assert email.html_body =~ user.email + assert email.html_body =~ project.name + assert email.html_body =~ "The Accent Team" + assert email.html_body =~ ~s(href="http://example.com">http://example.com) + assert email.html_body =~ ~s(href="http://example.com">login) + + assert email.text_body =~ user.email + assert email.text_body =~ project.name + assert email.text_body =~ "The Accent Team" + assert email.text_body =~ "(http://example.com)" + assert email.text_body =~ "(http://example.com)" + end +end diff --git a/test/graphql/helpers/authorization_test.exs b/test/graphql/helpers/authorization_test.exs new file mode 100644 index 00000000..d58d4726 --- /dev/null +++ b/test/graphql/helpers/authorization_test.exs @@ -0,0 +1,443 @@ +defmodule AccentTest.GraphQL.Helpers.Authorization do + use Accent.RepoCase + + alias Accent.{ + Repo, + ProjectCreator, + Collaborator, + User, + Language, + Document, + Integration, + Translation, + TranslationCommentsSubscription, + Version, + Operation, + GraphQL.Helpers.Authorization + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + version = Repo.insert!(%Version{project_id: project.id, name: "test", tag: "v1.0", user_id: user.id}) + translation = Repo.insert!(%Translation{revision_id: revision.id, key: "test", corrected_text: "bar"}) + collaborator = Repo.insert!(%Collaborator{project_id: project.id, user_id: user.id, role: "owner"}) + integration = Repo.insert!(%Integration{project_id: project.id, user_id: user.id, service: "slack", data: %{url: "http://example.com"}}) + translation_comments_subscription = Repo.insert!(%TranslationCommentsSubscription{translation_id: translation.id, user_id: user.id}) + + {:ok, + [ + project: project, + document: document, + revision: revision, + user: user, + version: version, + translation: translation, + collaborator: collaborator, + integration: integration, + translation_comments_subscription: translation_comments_subscription + ]} + end + + test "authorized viewer", %{user: user} do + root = %{user: user} + args = %{} + context = %{conn: %{}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.viewer_authorize(:index_permissions, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized viewer" do + root = %{user: nil} + args = %{} + context = %{conn: %{}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.viewer_authorize(:index_permissions, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized project root", %{user: user, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = project + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.project_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized project args", %{user: user, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: project.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.project_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized project role", %{user: user, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = project + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.project_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "unauthorized project root", %{project: project} do + user = %User{email: "test+2@test.com"} |> Repo.insert!() + user = Map.put(user, :permissions, %{}) + root = project + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.project_authorize(:show_project, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized revision root", %{user: user, revision: revision, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = revision + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.revision_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized revision args", %{user: user, revision: revision, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: revision.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.revision_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized revision role", %{user: user, revision: revision, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = revision + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.revision_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "unauthorized revision root", %{revision: revision} do + user = %User{email: "test+2@test.com"} |> Repo.insert!() + user = Map.put(user, :permissions, %{}) + root = revision + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.revision_authorize(:show_project, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized version root", %{user: user, version: version, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = version + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.version_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized version args", %{user: user, version: version, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: version.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.version_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized version role", %{user: user, version: version, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = version + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.version_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "unauthorized version root", %{version: version} do + user = %User{email: "test+2@test.com"} |> Repo.insert!() + user = Map.put(user, :permissions, %{}) + root = version + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.version_authorize(:show_project, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized translation root", %{user: user, translation: translation, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = translation + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.translation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized translation revision preloaded root", %{user: user, revision: revision, translation: translation, project: project} do + translation = %{translation | revision: revision} + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = translation + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.translation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized translation args", %{user: user, translation: translation, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: translation.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.translation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized translation role", %{user: user, translation: translation, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = translation + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.translation_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "unauthorized translation root", %{translation: translation} do + user = %User{email: "test+2@test.com"} |> Repo.insert!() + user = Map.put(user, :permissions, %{}) + root = translation + args = %{} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.translation_authorize(:show_project, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized document args", %{user: user, document: document, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: document.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.document_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized document role", %{user: user, document: document, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = nil + args = %{id: document.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.document_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized collaborator args", %{user: user, collaborator: collaborator, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: collaborator.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.collaborator_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized collaborator role", %{user: user, collaborator: collaborator, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = nil + args = %{id: collaborator.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.collaborator_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized integration args", %{user: user, integration: integration, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: integration.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.integration_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized integration role", %{user: user, integration: integration, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = nil + args = %{id: integration.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.integration_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized operation revision args", %{user: user, revision: revision, project: project} do + operation = Repo.insert!(%Operation{revision_id: revision.id, user_id: user.id, key: "test", text: "bar"}) + + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: operation.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.operation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized operation translation args", %{user: user, translation: translation, project: project} do + operation = Repo.insert!(%Operation{translation_id: translation.id, user_id: user.id, key: "test", text: "bar"}) + + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: operation.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.operation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "authorized operation project args", %{user: user, project: project} do + operation = Repo.insert!(%Operation{project_id: project.id, user_id: user.id, key: "test", text: "bar"}) + + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: operation.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.operation_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized operation role", %{user: user, revision: revision, project: project} do + operation = Repo.insert!(%Operation{revision_id: revision.id, user_id: user.id, key: "test", text: "bar"}) + + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = nil + args = %{id: operation.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.operation_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end + + test "authorized translation_comments_subscription args", %{user: user, translation_comments_subscription: translation_comments_subscription, project: project} do + user = Map.put(user, :permissions, %{project.id => "owner"}) + root = nil + args = %{id: translation_comments_subscription.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + Authorization.translation_comment_subscription_authorize(:show_project, resolver).(root, args, context) + + assert_receive :ok + end + + test "unauthorized translation_comments_subscription role", %{user: user, translation_comments_subscription: translation_comments_subscription, project: project} do + user = Map.put(user, :permissions, %{project.id => "reviewer"}) + root = nil + args = %{id: translation_comments_subscription.id} + context = %{context: %{conn: %{assigns: %{current_user: user}}}} + resolver = fn _, _, _ -> send(self(), :ok) end + + result = Authorization.translation_comment_subscription_authorize(:create_slave, resolver).(root, args, context) + + assert result == {:ok, nil} + refute_receive :ok + end +end diff --git a/test/graphql/helpers/fields_test.exs b/test/graphql/helpers/fields_test.exs new file mode 100644 index 00000000..fc5bc496 --- /dev/null +++ b/test/graphql/helpers/fields_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.GraphQL.Helpers.Fields do + use ExUnit.Case, async: true + doctest Accent.GraphQL.Helpers.Fields +end diff --git a/test/graphql/resolvers/access_token_test.exs b/test/graphql/resolvers/access_token_test.exs new file mode 100644 index 00000000..01c19620 --- /dev/null +++ b/test/graphql/resolvers/access_token_test.exs @@ -0,0 +1,22 @@ +defmodule AccentTest.GraphQL.Resolvers.AccessToken do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.AccessToken, as: Resolver + + alias Accent.{ + Repo, + Project, + User, + Collaborator, + AccessToken + } + + test "show project" do + user = %User{email: "test@example.com", bot: true} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + %Collaborator{project_id: project.id, user_id: user.id, role: "bot"} |> Repo.insert!() + token = %AccessToken{user_id: user.id, token: "foo"} |> Repo.insert!() + + assert Resolver.show_project(project, %{}, %{}) == {:ok, token.token} + end +end diff --git a/test/graphql/resolvers/activity_test.exs b/test/graphql/resolvers/activity_test.exs new file mode 100644 index 00000000..6302ddb1 --- /dev/null +++ b/test/graphql/resolvers/activity_test.exs @@ -0,0 +1,137 @@ +defmodule AccentTest.GraphQL.Resolvers.Activity do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Activity, as: Resolver + + alias Accent.{ + Repo, + Project, + Revision, + Translation, + User, + Language, + Operation + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: language.id, project_id: project.id, master: true} |> Repo.insert!() + translation = %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, [user: user, project: project, revision: revision, translation: translation]} + end + + test "list project", %{user: user, project: project, translation: translation, revision: revision} do + %Operation{user_id: user.id, translation_id: translation.id, revision_id: revision.id, key: translation.key, text: "foo", action: "update"} |> Repo.insert!() + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + {:ok, %{entries: entries, meta: meta}} = Resolver.list_project(project, %{}, %{}) + + assert entries |> Enum.count() == 2 + assert meta.current_page == 1 + assert meta.total_pages == 1 + assert meta.total_entries == 2 + assert meta.next_page == nil + assert meta.previous_page == nil + end + + test "list project paginated", %{user: user, project: project} do + for _index <- 1..100 do + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + end + + {:ok, %{entries: entries, meta: meta}} = Resolver.list_project(project, %{page: 3}, %{}) + + assert entries |> Enum.count() == 30 + assert meta.current_page == 3 + assert meta.total_pages == 4 + assert meta.total_entries == 100 + assert meta.next_page == 4 + assert meta.previous_page == 2 + end + + test "list project from user", %{user: user, project: project} do + other_user = %User{email: "foo@bar.com"} |> Repo.insert!() + %Operation{user_id: other_user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_project(project, %{user_id: other_user.id}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "list project from batch", %{user: user, project: project} do + %Operation{user_id: user.id, project_id: project.id, action: "sync", batch: true} |> Repo.insert!() + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_project(project, %{is_batch: true}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "list project from action", %{user: user, project: project} do + %Operation{user_id: user.id, project_id: project.id, action: "delete_document"} |> Repo.insert!() + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_project(project, %{action: "sync"}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "list translation", %{user: user, project: project, translation: translation, revision: revision} do + %Operation{user_id: user.id, translation_id: translation.id, revision_id: revision.id, key: translation.key, text: "foo", action: "update"} |> Repo.insert!() + %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + {:ok, %{entries: entries, meta: meta}} = Resolver.list_translation(translation, %{}, %{}) + + assert entries |> Enum.count() == 1 + assert meta.current_page == 1 + assert meta.total_pages == 1 + assert meta.total_entries == 1 + assert meta.next_page == nil + assert meta.previous_page == nil + end + + test "list translation from user", %{user: user, translation: translation} do + other_user = %User{email: "foo@bar.com"} |> Repo.insert!() + %Operation{user_id: other_user.id, translation_id: translation.id, action: "update"} |> Repo.insert!() + %Operation{user_id: user.id, translation_id: translation.id, action: "update"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_translation(translation, %{user_id: other_user.id}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "list translation from batch", %{user: user, translation: translation} do + %Operation{user_id: user.id, translation_id: translation.id, action: "sync", batch: true} |> Repo.insert!() + %Operation{user_id: user.id, translation_id: translation.id, action: "update"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_translation(translation, %{is_batch: true}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "list translation from action", %{user: user, translation: translation} do + %Operation{user_id: user.id, translation_id: translation.id, action: "delete_document"} |> Repo.insert!() + %Operation{user_id: user.id, translation_id: translation.id, action: "update"} |> Repo.insert!() + + {:ok, %{entries: entries}} = Resolver.list_translation(translation, %{action: "update"}, %{}) + + assert entries |> Enum.count() == 1 + end + + test "show project", %{user: user, project: project} do + operation = %Operation{user_id: user.id, project_id: project.id, action: "sync"} |> Repo.insert!() + + {:ok, %{id: id}} = Resolver.show_project(project, %{id: operation.id}, %{}) + + assert id == operation.id + end +end diff --git a/test/graphql/resolvers/collaborator_test.exs b/test/graphql/resolvers/collaborator_test.exs new file mode 100644 index 00000000..e7ad9e13 --- /dev/null +++ b/test/graphql/resolvers/collaborator_test.exs @@ -0,0 +1,61 @@ +defmodule AccentTest.GraphQL.Resolvers.Collaborator do + use Accent.RepoCase + + import Mox + setup :verify_on_exit! + + alias Accent.GraphQL.Resolvers.Collaborator, as: Resolver + + alias Accent.{ + Repo, + Project, + Collaborator, + User + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + project = %Project{name: "My project"} |> Repo.insert!() + + {:ok, [user: user, project: project]} + end + + test "create", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn _ -> :ok end) + + {:ok, result} = Resolver.create(project, %{email: "test@example.com", role: "admin"}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(Repo.all(Collaborator), [Access.all(), Access.key(:email)]) == ["test@example.com"] + assert get_in(Repo.all(Collaborator), [Access.all(), Access.key(:role)]) == ["admin"] + end + + test "update", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + collaborator = %Collaborator{email: "test@example.com", role: "reviewer", project_id: project.id} |> Repo.insert!() + + {:ok, result} = Resolver.update(collaborator, %{role: "owner"}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:collaborator, Access.key(:role)]) == "owner" + end + + test "delete", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + collaborator = %Collaborator{email: "test@example.com", role: "reviewer", project_id: project.id} |> Repo.insert!() + + {:ok, result} = Resolver.delete(collaborator, %{}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:collaborator]) == collaborator + end +end diff --git a/test/graphql/resolvers/comment_test.exs b/test/graphql/resolvers/comment_test.exs new file mode 100644 index 00000000..0c215d30 --- /dev/null +++ b/test/graphql/resolvers/comment_test.exs @@ -0,0 +1,63 @@ +defmodule AccentTest.GraphQL.Resolvers.Comment do + use Accent.RepoCase + + import Mox + setup :verify_on_exit! + + alias Accent.GraphQL.Resolvers.Comment, as: Resolver + + alias Accent.{ + Repo, + Project, + Comment, + Translation, + Revision, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + translation = %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, [user: user, project: project, translation: translation]} + end + + test "create", %{translation: translation, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn _ -> :ok end) + + {:ok, result} = Resolver.create(translation, %{text: "First comment"}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(Repo.all(Comment), [Access.all(), Access.key(:text)]) == ["First comment"] + end + + test "list project", %{project: project, translation: translation, user: user} do + comment = %Comment{translation_id: translation.id, text: "test", user: user} |> Repo.insert!() + + {:ok, result} = Resolver.list_project(project, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [comment.id] + end + + test "list translation", %{translation: translation, user: user} do + comment = %Comment{translation_id: translation.id, text: "test", user: user} |> Repo.insert!() + + {:ok, result} = Resolver.list_translation(translation, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [comment.id] + end +end diff --git a/test/graphql/resolvers/document_test.exs b/test/graphql/resolvers/document_test.exs new file mode 100644 index 00000000..79fd77ad --- /dev/null +++ b/test/graphql/resolvers/document_test.exs @@ -0,0 +1,67 @@ +defmodule AccentTest.GraphQL.Resolvers.Document do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Document, as: Resolver + + alias Accent.{ + Repo, + Project, + Document, + Translation, + Revision, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + document = %Document{project_id: project.id, path: "test", format: "json"} |> Repo.insert!() + + {:ok, [user: user, project: project, document: document, revision: revision]} + end + + test "delete", %{document: document, revision: revision, user: user} do + %Translation{revision_id: revision.id, document_id: document.id, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + {:ok, result} = Resolver.delete(document, %{}, context) + + assert get_in(result, [:errors]) == nil + end + + test "show project", %{document: document, project: project, revision: revision} do + %Translation{revision_id: revision.id, document_id: document.id, key: "ok", corrected_text: "bar", proposed_text: "bar", conflicted: false} |> Repo.insert!() + + {:ok, result} = Resolver.show_project(project, %{id: document.id}, %{}) + + assert get_in(result, [Access.key(:id)]) == document.id + assert get_in(result, [Access.key(:translations_count)]) == 1 + assert get_in(result, [Access.key(:conflicts_count)]) == 0 + assert get_in(result, [Access.key(:reviewed_count)]) == 1 + end + + test "list project", %{document: document, project: project, revision: revision} do + other_document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + _empty_document = %Document{project_id: project.id, path: "test3", format: "json"} |> Repo.insert!() + + %Translation{revision_id: revision.id, document_id: document.id, key: "ok", corrected_text: "bar", proposed_text: "bar", conflicted: false} |> Repo.insert!() + %Translation{revision_id: revision.id, document_id: other_document.id, key: "ok", corrected_text: "bar", proposed_text: "bar", conflicted: true} |> Repo.insert!() + + {:ok, result} = Resolver.list_project(project, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [other_document.id, document.id] + assert get_in(result, [:entries, Access.all(), Access.key(:translations_count)]) == [1, 1] + assert get_in(result, [:entries, Access.all(), Access.key(:conflicts_count)]) == [1, 0] + assert get_in(result, [:entries, Access.all(), Access.key(:reviewed_count)]) == [0, 1] + end +end diff --git a/test/graphql/resolvers/integration_test.exs b/test/graphql/resolvers/integration_test.exs new file mode 100644 index 00000000..6c01fd0d --- /dev/null +++ b/test/graphql/resolvers/integration_test.exs @@ -0,0 +1,55 @@ +defmodule AccentTest.GraphQL.Resolvers.Integration do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Integration, as: Resolver + + alias Accent.{ + Repo, + Project, + Integration, + User + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + project = %Project{name: "My project"} |> Repo.insert!() + + {:ok, [user: user, project: project]} + end + + test "create", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(project, %{service: "slack", events: ["sync"], data: %{url: "http://google.ca"}}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(Repo.all(Integration), [Access.all(), Access.key(:service)]) == ["slack"] + assert get_in(Repo.all(Integration), [Access.all(), Access.key(:events)]) == [["sync"]] + end + + test "update", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + integration = %Integration{project_id: project.id, user_id: user.id, service: "slack", events: ["sync"], data: %{url: "http://google.ca"}} |> Repo.insert!() + + {:ok, result} = Resolver.update(integration, %{data: %{url: "http://example.com/update"}}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:integration, Access.key(:data), Access.key(:url)]) == "http://example.com/update" + end + + test "delete", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + integration = %Integration{project_id: project.id, user_id: user.id, service: "slack", events: ["sync"], data: %{url: "http://google.ca"}} |> Repo.insert!() + + {:ok, result} = Resolver.delete(integration, %{}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:integration, Access.key(:__meta__), Access.key(:state)]) == :deleted + end +end diff --git a/test/graphql/resolvers/language_test.exs b/test/graphql/resolvers/language_test.exs new file mode 100644 index 00000000..3418261c --- /dev/null +++ b/test/graphql/resolvers/language_test.exs @@ -0,0 +1,23 @@ +defmodule AccentTest.GraphQL.Resolvers.Language do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Language, as: Resolver + + test "list" do + {:ok, result} = Resolver.list(nil, %{}, %{}) + + assert length(result.entries) == 10 + end + + test "list search" do + {:ok, result} = Resolver.list(nil, %{query: "mbourgis"}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:name)]) == ["Luxembourgish"] + end + + test "list empty search" do + {:ok, result} = Resolver.list(nil, %{query: ""}, %{}) + + assert length(result.entries) == 10 + end +end diff --git a/test/graphql/resolvers/permission_test.exs b/test/graphql/resolvers/permission_test.exs new file mode 100644 index 00000000..ad114d4e --- /dev/null +++ b/test/graphql/resolvers/permission_test.exs @@ -0,0 +1,44 @@ +defmodule AccentTest.GraphQL.Resolvers.Permission do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Permission, as: Resolver + + alias Accent.{ + Repo, + Project, + User + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + project = %Project{name: "My project"} |> Repo.insert!() + + {:ok, [user: user, project: project]} + end + + test "list project as owner", %{user: user, project: project} do + user = %{user | permissions: %{project.id => "owner"}} + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, permissions} = Resolver.list_project(project, %{}, context) + + assert :create_slave in permissions + assert :show_project_access_token in permissions + assert :show_project in permissions + end + + test "list project as reviewer", %{user: user, project: project} do + user = %{user | permissions: %{project.id => "reviewer"}} + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, permissions} = Resolver.list_project(project, %{}, context) + + assert :create_slave not in permissions + end +end diff --git a/test/graphql/resolvers/project_test.exs b/test/graphql/resolvers/project_test.exs new file mode 100644 index 00000000..2b1b5b3f --- /dev/null +++ b/test/graphql/resolvers/project_test.exs @@ -0,0 +1,164 @@ +defmodule AccentTest.GraphQL.Resolvers.Project do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Project, as: Resolver + + alias Accent.{ + Repo, + ProjectCreator, + Project, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + user = %{user | permissions: %{project.id => "owner"}} + + {:ok, + [ + project: project, + language: language, + user: user + ]} + end + + test "list viewer", %{user: user, project: project} do + Repo.insert!(%Project{name: "Other project"}) + + {:ok, result} = Resolver.list_viewer(user, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [project.id] + assert get_in(result, [:meta, Access.key(:current_page)]) == 1 + assert get_in(result, [:meta, Access.key(:total_pages)]) == 1 + assert get_in(result, [:meta, Access.key(:total_entries)]) == 1 + assert get_in(result, [:meta, Access.key(:next_page)]) == nil + assert get_in(result, [:meta, Access.key(:previous_page)]) == nil + end + + test "list viewer search", %{user: user, language: language} do + Repo.insert!(%Project{name: "Other project"}) + {:ok, project_two} = ProjectCreator.create(params: %{name: "My second project", language_id: language.id}, user: user) + ProjectCreator.create(params: %{name: "My third project", language_id: language.id}, user: user) + + {:ok, result} = Resolver.list_viewer(user, %{query: "second"}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [project_two.id] + end + + test "list viewer empty search", %{user: user, project: project} do + {:ok, result} = Resolver.list_viewer(user, %{query: ""}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [project.id] + assert get_in(result, [:meta, Access.key(:current_page)]) == 1 + assert get_in(result, [:meta, Access.key(:total_pages)]) == 1 + assert get_in(result, [:meta, Access.key(:total_entries)]) == 1 + assert get_in(result, [:meta, Access.key(:next_page)]) == nil + assert get_in(result, [:meta, Access.key(:previous_page)]) == nil + end + + test "list viewer paginated", %{user: user, language: language} do + for index <- 1..50 do + ProjectCreator.create(params: %{name: "My project #{index}", language_id: language.id}, user: user) + end + + {:ok, %{entries: entries, meta: meta}} = Resolver.list_viewer(user, %{}, %{}) + + assert entries |> Enum.count() == 30 + assert meta.current_page == 1 + assert meta.total_pages == 2 + assert meta.total_entries == 51 + assert meta.next_page == 2 + assert meta.previous_page == nil + end + + test "list viewer paginated page 2", %{user: user, language: language} do + for index <- 1..50 do + ProjectCreator.create(params: %{name: "My project #{index}", language_id: language.id}, user: user) + end + + {:ok, %{entries: entries, meta: meta}} = Resolver.list_viewer(user, %{page: 2}, %{}) + + assert entries |> Enum.count() == 21 + assert meta.current_page == 2 + assert meta.total_pages == 2 + assert meta.total_entries == 51 + assert meta.next_page == nil + assert meta.previous_page == 1 + end + + test "list viewer ordering", %{user: user, language: language, project: project_one} do + Repo.insert!(%Project{name: "Other project"}) + {:ok, project_two} = ProjectCreator.create(params: %{name: "X - My second project", language_id: language.id}, user: user) + {:ok, project_three} = ProjectCreator.create(params: %{name: "A - My third project", language_id: language.id}, user: user) + + {:ok, result} = Resolver.list_viewer(user, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [project_three.id, project_one.id, project_two.id] + end + + test "show viewer", %{user: user, project: project} do + {:ok, result} = Resolver.show_viewer(user, %{id: project.id}, %{}) + + assert result.id == project.id + end + + test "create", %{user: user, language: language} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(nil, %{language_id: language.id, name: "Foo bar"}, context) + + assert get_in(result, [:project, Access.key(:name)]) == "Foo bar" + end + + test "create without name", %{user: user, language: language} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(nil, %{language_id: language.id, name: ""}, context) + + assert get_in(result, [:project]) == nil + assert get_in(result, [:errors]) == ["unprocessable_entity"] + end + + test "create without language", %{user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(nil, %{language_id: nil, name: "FOO"}, context) + + assert get_in(result, [:project]) == nil + assert get_in(result, [:errors]) == ["unprocessable_entity"] + end + + test "delete", %{user: user, project: project} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.delete(project, %{}, context) + + assert Repo.all(Ecto.assoc(project, :collaborators)) === [] + assert get_in(result, [:project]) == project + end + + test "update", %{user: user, project: project} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.update(project, %{name: "Foo bar"}, context) + + assert get_in(result, [:project, Access.key(:name)]) == "Foo bar" + end + + test "update with file operation locked", %{user: user, project: project} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.update(project, %{name: project.name, is_file_operations_locked: true}, context) + + assert get_in(result, [:project, Access.key(:locked_file_operations)]) == true + end +end diff --git a/test/graphql/resolvers/revision_test.exs b/test/graphql/resolvers/revision_test.exs new file mode 100644 index 00000000..38a27145 --- /dev/null +++ b/test/graphql/resolvers/revision_test.exs @@ -0,0 +1,94 @@ +defmodule AccentTest.GraphQL.Resolvers.Revision do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Revision, as: Resolver + + alias Accent.{ + Repo, + Project, + Revision, + Translation, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french"} |> Repo.insert!() + english_language = %Language{name: "english"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + master_revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + slave_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: master_revision.id} |> Repo.insert!() + + {:ok, [user: user, project: project, master_revision: master_revision, slave_revision: slave_revision]} + end + + test "delete", %{slave_revision: revision} do + {:ok, result} = Resolver.delete(revision, %{}, %{}) + + assert get_in(result, [:errors]) == nil + end + + test "promote master", %{slave_revision: revision} do + {:ok, result} = Resolver.promote_master(revision, %{}, %{}) + + assert get_in(result, [:revision, Access.key(:master)]) == true + end + + test "create", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + language = %Language{name: "spanish"} |> Repo.insert!() + + {:ok, result} = Resolver.create(project, %{language_id: language.id}, context) + + assert get_in(result, [:revision, Access.key(:language_id)]) == language.id + assert get_in(result, [:errors]) == nil + end + + test "correct all", %{master_revision: revision, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", conflicted: true} |> Repo.insert!() + + {:ok, result} = Resolver.correct_all(revision, %{}, context) + + assert get_in(result, [:revision, Access.key(:translations_count)]) == 1 + assert get_in(result, [:revision, Access.key(:conflicts_count)]) == 0 + assert get_in(result, [:revision, Access.key(:reviewed_count)]) == 1 + end + + test "uncorrect all", %{master_revision: revision, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", conflicted: false} |> Repo.insert!() + + {:ok, result} = Resolver.uncorrect_all(revision, %{}, context) + + assert get_in(result, [:revision, Access.key(:translations_count)]) == 1 + assert get_in(result, [:revision, Access.key(:conflicts_count)]) == 1 + assert get_in(result, [:revision, Access.key(:reviewed_count)]) == 0 + end + + test "show project", %{master_revision: revision, project: project} do + {:ok, result} = Resolver.show_project(project, %{id: revision.id}, %{}) + + assert get_in(result, [Access.key(:id)]) == revision.id + assert get_in(result, [Access.key(:translations_count)]) == 0 + assert get_in(result, [Access.key(:conflicts_count)]) == 0 + assert get_in(result, [Access.key(:reviewed_count)]) == 0 + end + + test "list project", %{master_revision: master_revision, slave_revision: slave_revision, project: project} do + {:ok, result} = Resolver.list_project(project, %{}, %{}) + + assert get_in(result, [Access.all(), Access.key(:id)]) == [master_revision.id, slave_revision.id] + assert get_in(result, [Access.all(), Access.key(:translations_count)]) == [0, 0] + assert get_in(result, [Access.all(), Access.key(:conflicts_count)]) == [0, 0] + assert get_in(result, [Access.all(), Access.key(:reviewed_count)]) == [0, 0] + end +end diff --git a/test/graphql/resolvers/translation_comment_subscription_test.exs b/test/graphql/resolvers/translation_comment_subscription_test.exs new file mode 100644 index 00000000..15ac7d68 --- /dev/null +++ b/test/graphql/resolvers/translation_comment_subscription_test.exs @@ -0,0 +1,55 @@ +defmodule AccentTest.GraphQL.Resolvers.TranslationCommentSubscription do + use Accent.RepoCase + + import Mox + setup :verify_on_exit! + + alias Accent.GraphQL.Resolvers.TranslationCommentSubscription, as: Resolver + + alias Accent.{ + Repo, + Project, + TranslationCommentsSubscription, + Translation, + Revision, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + + {:ok, [user: user, project: project, revision: revision]} + end + + test "create", %{user: user, revision: revision} do + translation = %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(translation, %{user_id: user.id}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(Repo.all(TranslationCommentsSubscription), [Access.all(), Access.key(:user_id)]) == [user.id] + end + + test "delete", %{user: user, revision: revision} do + translation = %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + subscription = %TranslationCommentsSubscription{user_id: user.id, translation_id: translation.id} |> Repo.insert!() + + {:ok, result} = Resolver.delete(subscription, %{}, context) + + assert get_in(result, [:errors]) == nil + assert Repo.all(TranslationCommentsSubscription) == [] + end +end diff --git a/test/graphql/resolvers/translation_test.exs b/test/graphql/resolvers/translation_test.exs new file mode 100644 index 00000000..95db5a00 --- /dev/null +++ b/test/graphql/resolvers/translation_test.exs @@ -0,0 +1,179 @@ +defmodule AccentTest.GraphQL.Resolvers.Translation do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Translation, as: Resolver + + alias Accent.{ + Repo, + Project, + Translation, + Revision, + Document, + Version, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, [user: user, project: project, revision: revision, context: context]} + end + + test "correct", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.correct(translation, %{text: "Corrected text"}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:translation, Access.key(:id)]) == translation.id + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:corrected_text)]) == ["Corrected text"] + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:conflicted)]) == [false] + end + + test "uncorrect", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: false, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.uncorrect(translation, %{}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:translation, Access.key(:id)]) == translation.id + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:corrected_text)]) == ["bar"] + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:conflicted)]) == [true] + end + + test "update", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.update(translation, %{text: "Updated text"}, context) + + assert get_in(result, [:errors]) == nil + assert get_in(result, [:translation, Access.key(:id)]) == translation.id + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:corrected_text)]) == ["Updated text"] + assert get_in(Repo.all(Translation), [Access.all(), Access.key(:conflicted)]) == [true] + end + + test "show project", %{project: project, revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.show_project(project, %{id: translation.id}, context) + + assert get_in(result, [Access.key(:id)]) == translation.id + end + + test "show project unknown id", %{project: project, context: context} do + {:ok, result} = Resolver.show_project(project, %{id: Ecto.UUID.generate()}, context) + + assert is_nil(result) + end + + test "show project unknown project", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.show_project(%Project{id: Ecto.UUID.generate()}, %{id: translation.id}, context) + + assert is_nil(result) + end + + test "list revision", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [translation.id] + end + + test "list revision with query", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + %Translation{revision_id: revision.id, conflicted: true, key: "aux", corrected_text: "foo", proposed_text: "foo"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{query: "bar"}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [translation.id] + end + + test "list revision with document", %{project: project, revision: revision, context: context} do + document = %Document{path: "bar", format: "json", project_id: project.id} |> Repo.insert!() + other_document = %Document{path: "foo", format: "json", project_id: project.id} |> Repo.insert!() + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "foo", proposed_text: "foo", document_id: other_document.id} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{document: document.id}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [translation.id] + end + + test "list revision with order", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: true, key: "aaaaaa", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + other_translation = %Translation{revision_id: revision.id, conflicted: true, key: "bbbbb", corrected_text: "foo", proposed_text: "foo"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{order: "-key"}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [other_translation.id, translation.id] + end + + test "list revision with conflicted", %{revision: revision, context: context} do + translation = %Translation{revision_id: revision.id, conflicted: false, key: "bar", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + %Translation{revision_id: revision.id, conflicted: true, key: "foo", corrected_text: "foo", proposed_text: "foo"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{is_conflicted: false}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [translation.id] + end + + test "list revision with version", %{project: project, revision: revision, user: user, context: context} do + version = %Version{name: "bar", tag: "v1.0", project_id: project.id, user_id: user.id} |> Repo.insert!() + other_version = %Version{name: "foo", tag: "v2.0", project_id: project.id, user_id: user.id} |> Repo.insert!() + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar", version_id: version.id} |> Repo.insert!() + %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "foo", proposed_text: "foo", version_id: other_version.id} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{version: version.id}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [translation.id] + end + + test "list revision with reference_revision", %{project: project, revision: revision, context: context} do + english_language = %Language{name: "english"} |> Repo.insert!() + other_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: revision.id} |> Repo.insert!() + + %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + other_translation = %Translation{revision_id: other_revision.id, conflicted: true, key: "ok", corrected_text: "foo", proposed_text: "foo"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{reference_revision: other_revision.id}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:related_translation), Access.key(:id)]) == [other_translation.id] + end + + test "list revision with reference_revision without other revision translation", %{project: project, revision: revision, context: context} do + english_language = %Language{name: "english"} |> Repo.insert!() + other_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: revision.id} |> Repo.insert!() + %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + + {:ok, result} = Resolver.list_revision(revision, %{reference_revision: other_revision.id}, context) + + assert get_in(result, [:entries, Access.all(), Access.key(:related_translation)]) == [nil] + end + + test "related translations", %{project: project, revision: revision, context: context} do + english_language = %Language{name: "english"} |> Repo.insert!() + other_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: revision.id} |> Repo.insert!() + + translation = %Translation{revision_id: revision.id, conflicted: true, key: "ok", corrected_text: "bar", proposed_text: "bar"} |> Repo.insert!() + other_translation = %Translation{revision_id: other_revision.id, conflicted: true, key: "ok", corrected_text: "foo", proposed_text: "foo"} |> Repo.insert!() + + {:ok, result} = Resolver.related_translations(translation, %{}, context) + + assert get_in(result, [Access.all(), Access.key(:id)]) == [other_translation.id] + end +end diff --git a/test/graphql/resolvers/version_test.exs b/test/graphql/resolvers/version_test.exs new file mode 100644 index 00000000..a5cbea43 --- /dev/null +++ b/test/graphql/resolvers/version_test.exs @@ -0,0 +1,63 @@ +defmodule AccentTest.GraphQL.Resolvers.Version do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Version, as: Resolver + + alias Accent.{ + Repo, + Project, + User, + Version + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + project = %Project{name: "My project"} |> Repo.insert!() + + version = %Version{name: "version1", tag: "v1", project_id: project.id, user_id: user.id} |> Repo.insert!() + + {:ok, [user: user, project: project, version: version]} + end + + test "create", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(project, %{name: "foo", tag: "f"}, context) + + assert get_in(result, [:version, Access.key(:name)]) == "foo" + assert get_in(result, [:version, Access.key(:tag)]) == "f" + assert get_in(result, [:errors]) == nil + end + + test "create with error", %{project: project, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.create(project, %{name: "foo", tag: nil}, context) + + assert get_in(result, [:version]) == nil + assert get_in(result, [:errors]) == ["unprocessable_entity"] + end + + test "update", %{version: version, user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + + {:ok, result} = Resolver.update(version, %{name: "bar", tag: "b"}, context) + + assert get_in(result, [:version, Access.key(:id)]) == version.id + assert get_in(result, [:version, Access.key(:name)]) == "bar" + assert get_in(result, [:version, Access.key(:tag)]) == "b" + assert get_in(result, [:errors]) == nil + end + + test "list project", %{project: project, version: version} do + {:ok, result} = Resolver.list_project(project, %{}, %{}) + + assert get_in(result, [:entries, Access.all(), Access.key(:id)]) == [version.id] + end +end diff --git a/test/graphql/resolvers/viewer_test.exs b/test/graphql/resolvers/viewer_test.exs new file mode 100644 index 00000000..adcc591b --- /dev/null +++ b/test/graphql/resolvers/viewer_test.exs @@ -0,0 +1,34 @@ +defmodule AccentTest.GraphQL.Resolvers.Viewer do + use Accent.RepoCase + + alias Accent.GraphQL.Resolvers.Viewer, as: Resolver + + alias Accent.{ + Repo, + ProjectCreator, + User, + Language + } + + defmodule PlugConn do + defstruct [:assigns] + end + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + user = %{user | permissions: %{project.id => "owner"}} + + {:ok, [user: user]} + end + + test "show", %{user: user} do + context = %{context: %{conn: %PlugConn{assigns: %{current_user: user}}}} + {:ok, result} = Resolver.show(nil, %{}, context) + + assert result == user + end +end diff --git a/test/hook/consumers/slack_test.exs b/test/hook/consumers/slack_test.exs new file mode 100644 index 00000000..a65a3fae --- /dev/null +++ b/test/hook/consumers/slack_test.exs @@ -0,0 +1,61 @@ +defmodule MockHttpClient do + def post(url, body, header) do + send(:slack_consumer, {:post, %{url: url, body: body, header: header}}) + end +end + +defmodule AccentTest.Hook.Consumers.Slack do + use Accent.RepoCase + + alias Accent.{Repo, Project, User, Integration} + + setup do + project = %Project{name: "Test"} |> Repo.insert!() + user = %User{fullname: "Test", email: "foo@test.com"} |> Repo.insert!() + + %Integration{ + project: project, + user: user, + service: "slack", + events: ["sync"], + data: %{url: "http://example.com"} + } + |> Repo.insert!() + + [project: project, user: user] + end + + test "single event with single integration", %{project: project, user: user} do + Process.register(self(), :slack_consumer) + + payload = %{document_path: "foo.json", batch_operation_stats: [%{action: "new", count: 4}, %{action: "conflict_on_proposed", count: 10}]} + event = %Accent.Hook.Context{project: project, user: user, event: "sync", payload: payload} + events = [event] + + Accent.Hook.Consumers.Slack.handle_events(events, nil, {:http_client, MockHttpClient}) + + post_body = """ + *Test* just synced a file: _foo.json_ + + *Stats:* + new: _4_ + conflict_on_proposed: _10_ + + """ + + post_message = %{body: Poison.encode!(%{text: post_body}), header: [{"Content-Type", "application/json"}], url: "http://example.com"} + assert_receive {:post, ^post_message} + end + + test "unknown event", %{project: project, user: user} do + Process.register(self(), :slack_consumer) + + payload = %{document_path: "foo.json", batch_operation_stats: [%{action: "new", count: 4}]} + event = %Accent.Hook.Context{project: project, user: user, event: "merge", payload: payload} + events = [event] + + Accent.Hook.Consumers.Slack.handle_events(events, nil, {:http_client, MockHttpClient}) + + refute_received {:post, []} + end +end diff --git a/test/hook/producers/slack_test.exs b/test/hook/producers/slack_test.exs new file mode 100644 index 00000000..29965892 --- /dev/null +++ b/test/hook/producers/slack_test.exs @@ -0,0 +1,32 @@ +defmodule TestConsumer do + use GenStage + + def start_link(producer) do + GenStage.start_link(__MODULE__, {producer, self()}) + end + + def init({producer, owner}) do + {:consumer, owner, subscribe_to: [producer]} + end + + def handle_events(events, _from, owner) do + send(owner, {:received, events}) + {:noreply, [], owner} + end +end + +defmodule AccentTest.Hook.Producers.Slack do + use ExUnit.Case, async: true + + test "a subscribed observer is notified of all events" do + {:ok, stage} = GenStage.start_link(Accent.Hook.Producers.Slack, :ok) + {:ok, _} = TestConsumer.start_link(stage) + + GenStage.call(stage, {:notify, "LOL"}) + + assert_receive {:received, events} + assert events == ["LOL"] + + GenStage.stop(stage) + end +end diff --git a/test/langue/android/expectation_test.exs b/test/langue/android/expectation_test.exs new file mode 100644 index 00000000..3c5aa468 --- /dev/null +++ b/test/langue/android/expectation_test.exs @@ -0,0 +1,160 @@ +defmodule AccentTest.Formatter.Android.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + + + Ouvrir avec Chrome + Ouvrir avec Safari + + """ + end + + def entries do + [ + %Entry{index: 1, key: "activity_open_in_chrome", value: "Ouvrir avec Chrome", comment: ""}, + %Entry{index: 2, key: "activity_open_in_safari", value: "Ouvrir avec Safari", comment: ""} + ] + end + end + + defmodule EmptyValue do + use Langue.Expectation.Case + + def render do + """ + + + + + """ + end + + def entries do + [ + %Entry{index: 1, key: "activity_open_in_chrome", value: "", comment: "", value_type: "empty"} + ] + end + end + + defmodule UnsupportedTag do + use Langue.Expectation.Case + + def render do + """ + + + + + """ + end + + def entries do + [] + end + end + + defmodule RuntimeError do + use Langue.Expectation.Case + + def render do + """ + + + """ + end + + def entries do + [] + end + end + + defmodule Commented do + use Langue.Expectation.Case + + def render do + """ + + + + Ouvrir avec Chrome + Ouvrir avec Safari + + """ + end + + def entries do + [ + %Entry{index: 1, key: "activity_open_in_chrome", value: "Ouvrir avec Chrome", comment: " Comment "}, + %Entry{index: 2, key: "activity_open_in_safari", value: "Ouvrir avec Safari", comment: ""} + ] + end + end + + defmodule Array do + use Langue.Expectation.Case + + def render do + """ + + + + @string/browse_profile_view_title + @string/find_a_user + + + """ + end + + def entries do + [ + %Entry{index: 1, key: "drawer_menu_array.__KEY__0", value: "@string/browse_profile_view_title", comment: "", value_type: "array"}, + %Entry{index: 2, key: "drawer_menu_array.__KEY__1", value: "@string/find_a_user", comment: "", value_type: "array"} + ] + end + end + + defmodule StringsFormatEscape do + use Langue.Expectation.Case + + def render do + """ + + + Height (%s) + By using this application, you agree to the %1$s and %2$s. + + """ + end + + def entries do + [ + %Entry{index: 1, key: "height", value: "Height (%@)", comment: ""}, + %Entry{index: 2, key: "agree_terms_policy", value: "By using this application, you agree to the %1$@ and %2$@.", comment: ""} + ] + end + end + + defmodule ValueEscaping do + use Langue.Expectation.Case + + def render do + """ + + + Test & 1,2,4 < > j\\'appelle + + """ + end + + def entries do + [ + %Entry{index: 1, key: "a", value: "Test & 1,2,4 < > j'appelle", comment: ""} + ] + end + end +end diff --git a/test/langue/android/formatter_test.exs b/test/langue/android/formatter_test.exs new file mode 100644 index 00000000..235f227f --- /dev/null +++ b/test/langue/android/formatter_test.exs @@ -0,0 +1,39 @@ +defmodule AccentTest.Formatter.Android.Formatter do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.Android.Expectation.{Simple, EmptyValue, UnsupportedTag, RuntimeError, Commented, Array, ValueEscaping} + alias Langue.Formatter.Android.{Parser, Serializer} + alias Accent.FormatterTestHelper + + @tests [ + Simple, + EmptyValue, + Commented, + Array, + ValueEscaping + ] + + test "android XML" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end + + test "android XML unsupported tag" do + {expected_parse, result_parse} = FormatterTestHelper.test_parse(UnsupportedTag, Parser) + + assert expected_parse == result_parse + end + + test "android XML with runtime error" do + {_, result_parse} = FormatterTestHelper.test_parse(RuntimeError, Parser) + + assert result_parse == [] + end +end diff --git a/test/langue/es6_module/expectation_test.exs b/test/langue/es6_module/expectation_test.exs new file mode 100644 index 00000000..3b501e9a --- /dev/null +++ b/test/langue/es6_module/expectation_test.exs @@ -0,0 +1,29 @@ +defmodule AccentTest.Formatter.Es6Module.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + export default { + "general": { + "nested": "value ok", + "roles": { + "owner": "Owner" + } + }, + "test": "OK" + }; + """ + end + + def entries do + [ + %Entry{index: 1, key: "general.nested", value: "value ok", comment: ""}, + %Entry{index: 2, key: "general.roles.owner", value: "Owner", comment: ""}, + %Entry{index: 3, key: "test", value: "OK", comment: ""} + ] + end + end +end diff --git a/test/langue/es6_module/formatter_test.exs b/test/langue/es6_module/formatter_test.exs new file mode 100644 index 00000000..c20c294d --- /dev/null +++ b/test/langue/es6_module/formatter_test.exs @@ -0,0 +1,20 @@ +defmodule AccentTest.Formatter.Es6Module.Formatter do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.Es6Module.Expectation.{Simple} + alias Langue.Formatter.Es6Module.{Parser, Serializer} + + @tests [ + {:test_parse, Simple, Parser}, + {:test_serialize, Simple, Serializer} + ] + + test "es6 module" do + Enum.each(@tests, fn {fun, ex, mo} -> + {expected, result} = apply(Accent.FormatterTestHelper, fun, [ex, mo]) + assert expected == result + end) + end +end diff --git a/test/langue/gettext/expectation_test.exs b/test/langue/gettext/expectation_test.exs new file mode 100644 index 00000000..981458f1 --- /dev/null +++ b/test/langue/gettext/expectation_test.exs @@ -0,0 +1,94 @@ +defmodule AccentTest.Formatter.Gettext.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + #{top_of_the_file_comment()}msgid "" + msgstr "" + #{header()} + ## From Ecto.Changeset.cast/4 + msgid "can't be blank" + msgstr "ne peut être vide" + + ## From Ecto.Changeset.unique_constraint/3 + msgid "has already been taken" + msgstr "est déjà pris" + """ + end + + def entries do + [ + %Entry{comment: "## From Ecto.Changeset.cast/4", index: 1, key: "can't be blank", value: "ne peut être vide"}, + %Entry{comment: "## From Ecto.Changeset.unique_constraint/3", index: 2, key: "has already been taken", value: "est déjà pris"} + ] + end + + def top_of_the_file_comment do + ~S""" + ## `msgid`s in this file come from POT (.pot) files. + ## + ## Do not add, change, or remove `msgid`s manually here as + ## they're tied to the ones in the corresponding POT file + ## (with the same domain). + ## + ## Use `mix gettext.extract --merge` or `mix gettext.merge` + ## to merge POT files into PO files. + """ + end + + def header do + ~S""" + "Language: fr" + """ + end + end + + defmodule Pluralization do + use Langue.Expectation.Case + + def render do + """ + msgid "has already been taken" + msgstr "est déjà pris" + + msgid "should be at least n character(s)" + msgid_plural "should be at least %{count} character(s)" + msgstr[0] "should be at least 0 characters" + msgstr[1] "should be at least %{count} character(s)" + """ + end + + def entries do + [ + %Entry{index: 1, key: "has already been taken", value: "est déjà pris"}, + %Entry{index: 2, key: "should be at least n character(s).__KEY___", value: "should be at least %{count} character(s)", value_type: "plural"}, + %Entry{index: 2, key: "should be at least n character(s).__KEY__0", value: "should be at least 0 characters", value_type: "plural"}, + %Entry{index: 2, key: "should be at least n character(s).__KEY__1", value: "should be at least %{count} character(s)", value_type: "plural"} + ] + end + end + + defmodule DotKeys do + use Langue.Expectation.Case + + def render do + """ + msgid "has already been taken" + msgstr "est déjà pris" + + msgid "has.already.been.taken" + msgstr "est déjà pris" + """ + end + + def entries do + [ + %Entry{index: 1, key: "has already been taken", value: "est déjà pris"}, + %Entry{index: 2, key: "has.already.been.taken", value: "est déjà pris"} + ] + end + end +end diff --git a/test/langue/gettext/formatter_test.exs b/test/langue/gettext/formatter_test.exs new file mode 100644 index 00000000..b12ecc6a --- /dev/null +++ b/test/langue/gettext/formatter_test.exs @@ -0,0 +1,31 @@ +defmodule AccentTest.Formatter.Gettext.Parser do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias Accent.FormatterTestHelper + alias AccentTest.Formatter.Gettext.Expectation.{DotKeys, Pluralization, Simple} + alias Langue.Formatter.Gettext.{Parser, Serializer} + + @tests [ + DotKeys, + Pluralization, + Simple + ] + + test "gettext" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end + + test "language in header" do + {_, result_serialize} = FormatterTestHelper.test_serialize(Simple, Serializer, "en") + + assert result_serialize =~ "Language: en" + end +end diff --git a/test/langue/java_properties/expectation_test.exs b/test/langue/java_properties/expectation_test.exs new file mode 100644 index 00000000..db013107 --- /dev/null +++ b/test/langue/java_properties/expectation_test.exs @@ -0,0 +1,25 @@ +defmodule AccentTest.Formatter.JavaProperties.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + yes=Oui + url.hello=Bonjour + url.nested.ultra=I’m so nested + url.normal=Normal string + """ + end + + def entries do + [ + %Entry{key: "yes", value: "Oui", comment: "", index: 1}, + %Entry{key: "url.hello", value: "Bonjour", comment: "", index: 2}, + %Entry{key: "url.nested.ultra", value: "I’m so nested", comment: "", index: 3}, + %Entry{key: "url.normal", value: "Normal string", comment: "", index: 4} + ] + end + end +end diff --git a/test/langue/java_properties/formatter_test.exs b/test/langue/java_properties/formatter_test.exs new file mode 100644 index 00000000..9ef34a44 --- /dev/null +++ b/test/langue/java_properties/formatter_test.exs @@ -0,0 +1,22 @@ +defmodule AccentTest.Formatter.JavaProperties.Formatter do + Code.require_file("expectation_test.exs", __DIR__) + + use ExUnit.Case, async: true + + alias AccentTest.Formatter.JavaProperties.Expectation.{Simple} + alias Langue.Formatter.JavaProperties.{Parser, Serializer} + + @tests [ + Simple + ] + + test "java properties" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = Accent.FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = Accent.FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/langue/java_properties_xml/expectation_test.exs b/test/langue/java_properties_xml/expectation_test.exs new file mode 100644 index 00000000..bc3fc82d --- /dev/null +++ b/test/langue/java_properties_xml/expectation_test.exs @@ -0,0 +1,30 @@ +defmodule AccentTest.Formatter.JavaPropertiesXml.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + + + + + Oui + Bonjour + I’m so nested + Normal string + + """ + end + + def entries do + [ + %Entry{key: "yes", value: "Oui", comment: " ", index: 1}, + %Entry{key: "url.hello", value: "Bonjour", comment: "", index: 2}, + %Entry{key: "url.nested.ultra", value: "I’m so nested", comment: "", index: 3}, + %Entry{key: "url.normal", value: "Normal string", comment: "", index: 4} + ] + end + end +end diff --git a/test/langue/java_properties_xml/formatter_test.exs b/test/langue/java_properties_xml/formatter_test.exs new file mode 100644 index 00000000..50218516 --- /dev/null +++ b/test/langue/java_properties_xml/formatter_test.exs @@ -0,0 +1,22 @@ +defmodule AccentTest.Formatter.JavaPropertiesXml.Formatter do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.JavaPropertiesXml.Expectation.Simple + alias Langue.Formatter.JavaPropertiesXml.{Parser, Serializer} + + @tests [ + Simple + ] + + test "java properties xml" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = Accent.FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = Accent.FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/langue/json/expectation_test.exs b/test/langue/json/expectation_test.exs new file mode 100644 index 00000000..0bdf4a94 --- /dev/null +++ b/test/langue/json/expectation_test.exs @@ -0,0 +1,207 @@ +defmodule AccentTest.Formatter.Json.Expectation do + alias Langue.Entry + + defmodule Empty do + use Langue.Expectation.Case + + def render, do: "{\n \n}\n" + def entries, do: [] + end + + defmodule NilValue do + use Langue.Expectation.Case + + def render do + """ + { + "test": null + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "null", value_type: "null"} + ] + end + end + + defmodule EmptyValue do + use Langue.Expectation.Case + + def render do + """ + { + "test": "" + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "", value_type: "empty"} + ] + end + end + + defmodule BooleanValue do + use Langue.Expectation.Case + + def render do + """ + { + "test": false, + "test2": true + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "false", value_type: "boolean"}, + %Entry{comment: "", index: 2, key: "test2", value: "true", value_type: "boolean"} + ] + end + end + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + { + "test": "F", + "test2": "D", + "test3": "New history please" + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "F"}, + %Entry{comment: "", index: 2, key: "test2", value: "D"}, + %Entry{comment: "", index: 3, key: "test3", value: "New history please"} + ] + end + end + + defmodule Nested do + use Langue.Expectation.Case + + def render do + """ + { + "test": { + "nested": "A" + }, + "test2": { + "full": { + "nested": "B" + }, + "normal": "C" + } + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test.nested", value: "A"}, + %Entry{comment: "", index: 2, key: "test2.full.nested", value: "B"}, + %Entry{comment: "", index: 3, key: "test2.normal", value: "C"} + ] + end + end + + defmodule Complexe do + use Langue.Expectation.Case + + def render do + """ + { + "activerecord": { + "errors": { + "models": { + "result": { + "attributes": { + "video_url": { + "invalid_url": "n’est pas valide" + } + } + }, + "season": { + "attributes": { + "base": { + "current_season_must_be_unique": "Les saisons ne doivent pas se chevaucher. Une seule saison à la fois." + }, + "starts_at": { + "cant_be_changed": "ne peut pas être changé" + }, + "workouts_count": { + "cant_be_changed": "ne peut pas être changé" + } + } + } + } + } + }, + "attributes": { + "country_code": "Pays", + "credit_card": "Carte de crédit", + "email": "Courriel", + "first_name": "Prénom", + "last_name": "Nom", + "package": "Forfait", + "password": "Mot de passe", + "seasons": "Saisons" + }, + "array_type": [ + "foo", + { + "bar": "baz", + "aux": "zoo" + }, + { + "aa": "bb", + "cc": "dd", + "nested_array": [ + null, + "two" + ] + } + ] + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "activerecord.errors.models.result.attributes.video_url.invalid_url", value: "n’est pas valide"}, + %Entry{ + comment: "", + index: 2, + key: "activerecord.errors.models.season.attributes.base.current_season_must_be_unique", + value: "Les saisons ne doivent pas se chevaucher. Une seule saison à la fois." + }, + %Entry{comment: "", index: 3, key: "activerecord.errors.models.season.attributes.starts_at.cant_be_changed", value: "ne peut pas être changé"}, + %Entry{comment: "", index: 4, key: "activerecord.errors.models.season.attributes.workouts_count.cant_be_changed", value: "ne peut pas être changé"}, + %Entry{comment: "", index: 5, key: "attributes.country_code", value: "Pays"}, + %Entry{comment: "", index: 6, key: "attributes.credit_card", value: "Carte de crédit"}, + %Entry{comment: "", index: 7, key: "attributes.email", value: "Courriel"}, + %Entry{comment: "", index: 8, key: "attributes.first_name", value: "Prénom"}, + %Entry{comment: "", index: 9, key: "attributes.last_name", value: "Nom"}, + %Entry{comment: "", index: 10, key: "attributes.package", value: "Forfait"}, + %Entry{comment: "", index: 11, key: "attributes.password", value: "Mot de passe"}, + %Entry{comment: "", index: 12, key: "attributes.seasons", value: "Saisons"}, + %Entry{comment: "", index: 13, key: "array_type.__KEY__0", value: "foo"}, + %Entry{comment: "", index: 14, key: "array_type.__KEY__1.bar", value: "baz"}, + %Entry{comment: "", index: 15, key: "array_type.__KEY__1.aux", value: "zoo"}, + %Entry{comment: "", index: 16, key: "array_type.__KEY__2.aa", value: "bb"}, + %Entry{comment: "", index: 17, key: "array_type.__KEY__2.cc", value: "dd"}, + %Entry{comment: "", index: 18, key: "array_type.__KEY__2.nested_array.__KEY__0", value: "null", value_type: "null"}, + %Entry{comment: "", index: 19, key: "array_type.__KEY__2.nested_array.__KEY__1", value: "two"} + ] + end + end +end diff --git a/test/langue/json/formatter_test.exs b/test/langue/json/formatter_test.exs new file mode 100644 index 00000000..904f0d99 --- /dev/null +++ b/test/langue/json/formatter_test.exs @@ -0,0 +1,28 @@ +defmodule AccentTest.Formatter.Json.Parser do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.Json.Expectation.{Empty, NilValue, EmptyValue, BooleanValue, Simple, Nested, Complexe} + alias Langue.Formatter.Json.{Parser, Serializer} + + @tests [ + Empty, + NilValue, + EmptyValue, + BooleanValue, + Simple, + Nested, + Complexe + ] + + test "json" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = Accent.FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = Accent.FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/langue/rails/expectation_test.exs b/test/langue/rails/expectation_test.exs new file mode 100644 index 00000000..82de308f --- /dev/null +++ b/test/langue/rails/expectation_test.exs @@ -0,0 +1,93 @@ +defmodule AccentTest.Formatter.Rails.Expectation do + alias Langue.Entry + + defmodule NestedValues do + use Langue.Expectation.Case + + def render do + """ + "fr": + "errors": + "model": + "user": "Utilisateur" + "messages": + "invalid_email": "n’est pas une adresse courriel valide" + "invalid_url": "n’est pas un URL valide" + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "errors.model.user", value: "Utilisateur"}, + %Entry{comment: "", index: 2, key: "errors.messages.invalid_email", value: "n’est pas une adresse courriel valide"}, + %Entry{comment: "", index: 3, key: "errors.messages.invalid_url", value: "n’est pas un URL valide"} + ] + end + end + + defmodule EmptyValue do + use Langue.Expectation.Case + + def render do + """ + "fr": + "test": "" + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "", value_type: "empty"} + ] + end + end + + defmodule IntegerValues do + use Langue.Expectation.Case + + def render do + """ + "fr": + "count_somehting": 282 + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "count_somehting", value: "282", value_type: "integer"} + ] + end + end + + defmodule ArrayValues do + use Langue.Expectation.Case + + def render do + """ + "fr": + "errors": + - "First error" + - "Second error" + - + "nested": + - "of course" + - + "nested_agin": + - "ok" + - "it works" + "root": "AWESOME" + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "errors.__KEY__0", value: "First error"}, + %Entry{comment: "", index: 2, key: "errors.__KEY__1", value: "Second error"}, + %Entry{comment: "", index: 3, key: "errors.__KEY__2.nested.__KEY__0", value: "of course"}, + %Entry{comment: "", index: 4, key: "errors.__KEY__2.nested.__KEY__1.nested_agin.__KEY__0", value: "ok"}, + %Entry{comment: "", index: 5, key: "errors.__KEY__2.nested.__KEY__2", value: "it works"}, + %Entry{comment: "", index: 6, key: "root", value: "AWESOME"} + ] + end + end +end diff --git a/test/langue/rails/formatter_test.exs b/test/langue/rails/formatter_test.exs new file mode 100644 index 00000000..518c6d82 --- /dev/null +++ b/test/langue/rails/formatter_test.exs @@ -0,0 +1,25 @@ +defmodule AccentTest.Formatter.Rails.Formatter do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.Rails.Expectation.{EmptyValue, NestedValues, ArrayValues, IntegerValues} + alias Langue.Formatter.Rails.{Parser, Serializer} + + @tests [ + EmptyValue, + NestedValues, + ArrayValues, + IntegerValues + ] + + test "rails_yaml" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = Accent.FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = Accent.FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/langue/simple_json/expectation_test.exs b/test/langue/simple_json/expectation_test.exs new file mode 100644 index 00000000..67fe8904 --- /dev/null +++ b/test/langue/simple_json/expectation_test.exs @@ -0,0 +1,60 @@ +defmodule AccentTest.Formatter.SimpleJson.Expectation do + alias Langue.Entry + + defmodule Empty do + use Langue.Expectation.Case + + def render, do: "{\n \n}\n" + def entries, do: [] + end + + defmodule SimpleParse do + use Langue.Expectation.Case + + def render do + """ + { + "test": "F", + "test2": "D", + "test3": "New history please", + "test4": { + "key": "value" + } + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "F"}, + %Entry{comment: "", index: 2, key: "test2", value: "D"}, + %Entry{comment: "", index: 3, key: "test3", value: "New history please"}, + %Entry{comment: "", index: 4, key: "test4.key", value: "value"} + ] + end + end + + defmodule SimpleSerialize do + use Langue.Expectation.Case + + def render do + """ + { + "test": "F", + "test2": "D", + "test3": "New history please", + "test4.key": "value" + } + """ + end + + def entries do + [ + %Entry{comment: "", index: 1, key: "test", value: "F"}, + %Entry{comment: "", index: 2, key: "test2", value: "D"}, + %Entry{comment: "", index: 3, key: "test3", value: "New history please"}, + %Entry{comment: "", index: 4, key: "test4.key", value: "value"} + ] + end + end +end diff --git a/test/langue/simple_json/formatter_test.exs b/test/langue/simple_json/formatter_test.exs new file mode 100644 index 00000000..e2e6f263 --- /dev/null +++ b/test/langue/simple_json/formatter_test.exs @@ -0,0 +1,33 @@ +defmodule AccentTest.Formatter.SimpleJson.Parser do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias Accent.FormatterTestHelper + alias AccentTest.Formatter.SimpleJson.Expectation.{Empty, SimpleParse, SimpleSerialize} + alias Langue.Formatter.SimpleJson.{Parser, Serializer} + + @tests [ + Empty + ] + + test "simple json parse" do + {expected, result} = FormatterTestHelper.test_parse(SimpleParse, Parser) + assert expected == result + end + + test "simple json serialize" do + {expected, result} = FormatterTestHelper.test_serialize(SimpleSerialize, Serializer) + assert expected == result + end + + test "simple json" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/langue/strings/expectation_test.exs b/test/langue/strings/expectation_test.exs new file mode 100644 index 00000000..2a967b9f --- /dev/null +++ b/test/langue/strings/expectation_test.exs @@ -0,0 +1,92 @@ +defmodule AccentTest.Formatter.Strings.Expectation do + alias Langue.Entry + + defmodule Simple do + use Langue.Expectation.Case + + def render do + """ + "greeting" = "hello"; + "goodbye" = "Bye bye"; + """ + end + + def entries do + [ + %Entry{key: "greeting", value: "hello", comment: "", index: 1}, + %Entry{key: "goodbye", value: "Bye bye", comment: "", index: 2} + ] + end + end + + defmodule EmptyValue do + use Langue.Expectation.Case + + def render do + """ + "greeting" = ""; + "goodbye" = "Bye bye"; + """ + end + + def entries do + [ + %Entry{key: "greeting", value: "", comment: "", index: 1}, + %Entry{key: "goodbye", value: "Bye bye", comment: "", index: 2} + ] + end + end + + defmodule Commented do + use Langue.Expectation.Case + + def render do + """ + /* + Login text + */ + "app.login.text" = "Enter your credentials below to login"; + + /// Onboarding + "app.login.text" = "Username"; + + /* User state */ + "app.users.active" = "Just one user online"; + "app.users.unactive" = "No users online"; + """ + end + + def entries do + [ + %Entry{key: "app.login.text", value: "Enter your credentials below to login", comment: "/*\n Login text\n*/", index: 1}, + %Entry{key: "app.login.text", value: "Username", comment: "\n/// Onboarding", index: 2}, + %Entry{key: "app.users.active", value: "Just one user online", comment: "\n/* User state */", index: 3}, + %Entry{key: "app.users.unactive", value: "No users online", comment: "", index: 4} + ] + end + end + + defmodule Multiline do + use Langue.Expectation.Case + + def render do + """ + "app.feedback" = " + Comment: + \\n\\n\\n + --- + \\n\\nDevice information: + \\n\\nDevice: BLA + "; + "app.login.text" = "Username"; + """ + end + + def entries do + [ + %Entry{key: "app.feedback", value: "\n Comment:\n \\n\\n\\n\n ---\n \\n\\nDevice information:\n \\n\\nDevice: BLA\n", comment: "", index: 1}, + %Entry{key: "app.login.text", value: "Username", comment: "", index: 2} + ] + end + end +end diff --git a/test/langue/strings/formatter_test.exs b/test/langue/strings/formatter_test.exs new file mode 100644 index 00000000..6c048456 --- /dev/null +++ b/test/langue/strings/formatter_test.exs @@ -0,0 +1,25 @@ +defmodule AccentTest.Formatter.Strings.Formatter do + use ExUnit.Case, async: true + + Code.require_file("expectation_test.exs", __DIR__) + + alias AccentTest.Formatter.Strings.Expectation.{Simple, Commented, Multiline, EmptyValue} + alias Langue.Formatter.Strings.{Parser, Serializer} + + @tests [ + Simple, + EmptyValue, + Commented, + Multiline + ] + + test "strings" do + Enum.each(@tests, fn ex -> + {expected_parse, result_parse} = Accent.FormatterTestHelper.test_parse(ex, Parser) + {expected_serialize, result_serialize} = Accent.FormatterTestHelper.test_serialize(ex, Serializer) + + assert expected_parse == result_parse + assert expected_serialize == result_serialize + end) + end +end diff --git a/test/movement/builders/new_slave_test.exs b/test/movement/builders/new_slave_test.exs new file mode 100644 index 00000000..1ee889e8 --- /dev/null +++ b/test/movement/builders/new_slave_test.exs @@ -0,0 +1,105 @@ +defmodule AccentTest.Movement.Builders.NewSlave do + use Accent.RepoCase + + alias Movement.Builders.NewSlave, as: NewSlaveBuilder + alias Accent.ProjectCreator + + alias Accent.{ + Repo, + Translation, + User, + Language, + Document + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, project: project]} + end + + test "builder fetch translations and process operations", %{revision: revision, project: project, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + file_index: 2, + file_comment: "comment", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:project, project) + |> NewSlaveBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action, :text, :document_id, :file_comment, :file_index])) + + assert translation_ids === [translation.id] + + assert operations === [ + %{ + key: translation.key, + action: "new", + text: "A", + document_id: document.id, + file_index: 2, + file_comment: "comment" + } + ] + end + + test "with removed translation", %{revision: revision, project: project, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + file_index: 2, + file_comment: "comment", + revision_id: revision.id, + document_id: document.id, + removed: true + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:project, project) + |> NewSlaveBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action, :text, :document_id, :file_comment, :file_index, :previous_translation])) + + assert translation_ids === [translation.id] + + assert operations === [ + %{ + key: translation.key, + action: "new", + text: "A", + document_id: document.id, + file_index: 2, + file_comment: "comment", + previous_translation: %{ + "value_type" => nil, + "removed" => true, + "conflicted" => false, + "conflicted_text" => "", + "corrected_text" => "A", + "proposed_text" => "A" + } + } + ] + end +end diff --git a/test/movement/builders/new_version_test.exs b/test/movement/builders/new_version_test.exs new file mode 100644 index 00000000..3aa02820 --- /dev/null +++ b/test/movement/builders/new_version_test.exs @@ -0,0 +1,112 @@ +defmodule AccentTest.Movement.Builders.NewVersion do + use Accent.RepoCase + + alias Movement.Builders.NewVersion, as: NewVersionBuilder + alias Accent.ProjectCreator + + alias Accent.{ + Repo, + Translation, + User, + Language, + Version, + Document + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, project: project, user: user]} + end + + test "builder fetch translations and process operations", %{revision: revision, project: project, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + file_index: 2, + file_comment: "comment", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:project, project) + |> NewVersionBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action, :text, :document_id, :file_comment, :file_index])) + + assert translation_ids === [translation.id] + + assert operations == [ + %{ + key: translation.key, + action: "version_new", + text: "A", + document_id: document.id, + file_index: 2, + file_comment: "comment" + } + ] + end + + test "builder with existing version", %{revision: revision, project: project, document: document, user: user} do + version = %Version{user_id: user.id, tag: "v3.2", name: "Release", project_id: project.id} |> Repo.insert!() + + translation = + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + file_index: 2, + file_comment: "comment", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + file_index: 2, + file_comment: "comment", + revision_id: revision.id, + version_id: version.id, + document_id: document.id, + source_translation_id: translation.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:project, project) + |> NewVersionBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action, :text, :document_id, :file_comment, :file_index])) + + assert translation_ids === [translation.id] + + assert operations == [ + %{ + key: translation.key, + action: "version_new", + text: "A", + document_id: document.id, + file_index: 2, + file_comment: "comment" + } + ] + end +end diff --git a/test/movement/builders/project_sync_test.exs b/test/movement/builders/project_sync_test.exs new file mode 100644 index 00000000..13e83d7c --- /dev/null +++ b/test/movement/builders/project_sync_test.exs @@ -0,0 +1,76 @@ +defmodule AccentTest.Movement.Builders.ProjectSync do + use Accent.RepoCase + + alias Accent.{ + Document, + Language, + ProjectCreator, + Repo, + Revision, + Translation, + User + } + + alias Movement.Builders.ProjectSync, as: ProjectSyncBuilder + alias Movement.Context + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + other_language = Repo.insert!(%Language{name: "French", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + other_revision = Repo.insert!(%Revision{master: false, project_id: project.id, language_id: other_language.id}) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, project: project, other_revision: other_revision]} + end + + test "builder fetch translations and use process operations", %{revision: revision, document: document, project: project, other_revision: other_revision} do + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + %Translation{ + key: "b", + proposed_text: "B", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + %Translation{ + key: "a", + proposed_text: "C", + revision_id: other_revision.id, + document_id: document.id + } + |> Repo.insert!() + + entries = [%Langue.Entry{key: "a", value: "B"}] + + context = + %Context{entries: entries} + |> Context.assign(:comparer, fn x, _y -> + # Fake remove comparer in Movement.Builder + if x.key == "b" do + %Movement.Operation{action: "remove", key: x.key} + else + %Movement.Operation{action: "conflict_on_proposed", key: x.key} + end + end) + |> Context.assign(:project, project) + |> Context.assign(:document, document) + |> ProjectSyncBuilder.build() + + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert operations === ["conflict_on_proposed", "remove", "conflict_on_slave"] + end +end diff --git a/test/movement/builders/revision_correct_all_test.exs b/test/movement/builders/revision_correct_all_test.exs new file mode 100644 index 00000000..37181360 --- /dev/null +++ b/test/movement/builders/revision_correct_all_test.exs @@ -0,0 +1,66 @@ +defmodule AccentTest.Movement.Builders.RevisionCorrectAll do + use Accent.RepoCase + + alias Movement.Builders.RevisionCorrectAll, as: RevisionCorrectAllBuilder + + alias Accent.{ + Repo, + ProjectCreator, + Translation, + User, + Language + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "French", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + + {:ok, [revision: revision]} + end + + test "builder fetch translations and correct conflict", %{revision: revision} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + revision_id: revision.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:revision, revision) + |> RevisionCorrectAllBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["correct_conflict"] + end + + test "builder fetch translations and ignore corrected translation", %{revision: revision} do + %Translation{ + key: "a", + proposed_text: "A", + conflicted: false, + revision_id: revision.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:revision, revision) + |> RevisionCorrectAllBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + + assert translation_ids === [] + assert context.operations === [] + end +end diff --git a/test/movement/builders/revision_merge_test.exs b/test/movement/builders/revision_merge_test.exs new file mode 100644 index 00000000..c9d352da --- /dev/null +++ b/test/movement/builders/revision_merge_test.exs @@ -0,0 +1,49 @@ +defmodule AccentTest.Movement.Builders.RevisionMerge do + use Accent.RepoCase + + alias Accent.{ + Document, + Language, + ProjectCreator, + Repo, + Translation, + User + } + + alias Movement.Builders.RevisionMerge, as: RevisionMergeBuilder + alias Movement.Context + + @user %User{email: "test@test.com"} + + test "builder fetch translations and use comparer" do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + entries = [%Langue.Entry{key: "a", value: "B"}] + + context = + %Context{entries: entries} + |> Context.assign(:comparer, fn x, _y -> %Movement.Operation{action: "merge_on_proposed", key: x.key} end) + |> Context.assign(:document, document) + |> Context.assign(:revision, revision) + |> RevisionMergeBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["merge_on_proposed"] + end +end diff --git a/test/movement/builders/revision_sync_test.exs b/test/movement/builders/revision_sync_test.exs new file mode 100644 index 00000000..5ecf6341 --- /dev/null +++ b/test/movement/builders/revision_sync_test.exs @@ -0,0 +1,104 @@ +defmodule AccentTest.Movement.Builders.RevisionSync do + use Accent.RepoCase + + alias Accent.{ + Document, + Language, + ProjectCreator, + Repo, + Translation, + User + } + + alias Movement.Builders.RevisionSync, as: RevisionSyncBuilder + alias Movement.Context + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document]} + end + + test "builder fetch translations and use comparer", %{revision: revision, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + entries = [%Langue.Entry{key: "a", value: "B"}] + + context = + %Context{entries: entries} + |> Context.assign(:comparer, fn x, _y -> %Movement.Operation{action: "conflict_on_proposed", key: x.key} end) + |> Context.assign(:document, document) + |> Context.assign(:revision, revision) + |> RevisionSyncBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["conflict_on_proposed"] + end + + test "builder fetch translations and process to remove with empty entries", %{revision: revision, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + context = + %Context{entries: []} + |> Context.assign(:comparer, fn x, _y -> %Movement.Operation{action: "remove", key: x.key} end) + |> Context.assign(:document, document) + |> Context.assign(:revision, revision) + |> RevisionSyncBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["remove"] + end + + test "builder fetch translations and process to renew with entries", %{revision: revision, document: document} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id, + removed: true + } + |> Repo.insert!() + + entries = [%Langue.Entry{key: "a", value: "B"}] + + context = + %Context{entries: entries} + |> Context.assign(:comparer, fn x, _y -> %Movement.Operation{action: "renew", key: x.key} end) + |> Context.assign(:document, document) + |> Context.assign(:revision, revision) + |> RevisionSyncBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["renew"] + end +end diff --git a/test/movement/builders/revision_uncorrect_all_test.exs b/test/movement/builders/revision_uncorrect_all_test.exs new file mode 100644 index 00000000..117fed56 --- /dev/null +++ b/test/movement/builders/revision_uncorrect_all_test.exs @@ -0,0 +1,66 @@ +defmodule AccentTest.Movement.Builders.RevisionUncorrectAll do + use Accent.RepoCase + + alias Movement.Builders.RevisionUncorrectAll, as: RevisionUncorrectAllBuilder + + alias Accent.{ + ProjectCreator, + Repo, + Translation, + User, + Language + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + + {:ok, [revision: revision]} + end + + test "builder fetch translations and uncorrect conflict", %{revision: revision} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: false, + revision_id: revision.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:revision, revision) + |> RevisionUncorrectAllBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + assert translation_ids === [translation.id] + assert operations === ["uncorrect_conflict"] + end + + test "builder fetch translations and ignore conflicted translation", %{revision: revision} do + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + revision_id: revision.id + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:revision, revision) + |> RevisionUncorrectAllBuilder.build() + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + + assert translation_ids === [] + assert context.operations === [] + end +end diff --git a/test/movement/builders/rollback_test.exs b/test/movement/builders/rollback_test.exs new file mode 100644 index 00000000..89e62009 --- /dev/null +++ b/test/movement/builders/rollback_test.exs @@ -0,0 +1,97 @@ +defmodule AccentTest.Movement.Builders.Rollback do + use Accent.RepoCase + + alias Movement.Builders.Rollback, as: RollbackBuilder + + alias Accent.{ + Repo, + Translation, + Operation, + User, + Language, + ProjectCreator, + Document + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, project: project]} + end + + test "builder process operations for batch", %{project: project} do + operation = + %Operation{ + project_id: project.id, + batch: true, + action: "sync" + } + |> Repo.insert!() + + context = + %Movement.Context{} + |> Movement.Context.assign(:operation, operation) + |> RollbackBuilder.build() + + [new_operation] = context.operations + + assert new_operation.action === "rollback" + assert new_operation.rollbacked_operation_id === operation.id + end + + test "builder process operations for translation", %{project: project, revision: revision, document: document} do + translation = + %Translation{ + key: "A", + proposed_text: "TEXT", + conflicted_text: "Ex-TEXT", + corrected_text: "LOL", + removed: false, + revision_id: revision.id, + value_type: "string" + } + |> Repo.insert!() + + operation = + %Operation{ + text: "New text", + key: "A", + action: "update", + translation_id: translation.id, + revision_id: revision.id, + project_id: project.id, + document_id: document.id + } + |> Repo.insert!() + |> Repo.preload(:translation) + + context = + %Movement.Context{} + |> Movement.Context.assign(:operation, operation) + |> RollbackBuilder.build() + + [new_operation] = context.operations + + assert new_operation.action === "rollback" + assert new_operation.rollbacked_operation_id === operation.id + assert new_operation.translation_id === operation.translation_id + assert new_operation.revision_id === operation.revision_id + assert new_operation.project_id === operation.project_id + assert new_operation.document_id === operation.document_id + + assert new_operation.previous_translation === %{ + "value_type" => translation.value_type, + "proposed_text" => translation.proposed_text, + "corrected_text" => translation.corrected_text, + "conflicted_text" => translation.conflicted_text, + "conflicted" => translation.conflicted, + "removed" => translation.removed + } + end +end diff --git a/test/movement/builders/slave_conflict_sync_test.exs b/test/movement/builders/slave_conflict_sync_test.exs new file mode 100644 index 00000000..382c0ad2 --- /dev/null +++ b/test/movement/builders/slave_conflict_sync_test.exs @@ -0,0 +1,62 @@ +defmodule AccentTest.Movement.Builders.SlaveConflictSync do + use Accent.RepoCase + + alias Movement.Builders.SlaveConflictSync, as: SlaveConflictSyncBuilder + + alias Accent.{ + Repo, + ProjectCreator, + Translation, + User, + Language, + Revision, + Document + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + other_language = Repo.insert!(%Language{name: "French", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + other_revision = Repo.insert!(%Revision{project_id: project.id, language_id: other_language.id}) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, other_revision: other_revision]} + end + + test "builder fetch translations and use process operations", %{revision: revision, document: document, other_revision: other_revision} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + other_translation = + %Translation{ + key: "a", + proposed_text: "C", + revision_id: other_revision.id, + document_id: document.id + } + |> Repo.insert!() + + context = + %Movement.Context{operations: [%{key: "a", action: "conflict_on_proposed"}]} + |> Movement.Context.assign(:revisions, [revision, other_revision]) + |> SlaveConflictSyncBuilder.build() + + operations = context.operations |> Enum.map(&Map.get(&1, :action)) + + translation_ids = context.assigns[:translations] |> Enum.map(&Map.get(&1, :id)) + + assert Enum.member?(translation_ids, translation.id) + assert Enum.member?(translation_ids, other_translation.id) + assert operations === ["conflict_on_proposed", "conflict_on_slave", "conflict_on_slave"] + end +end diff --git a/test/movement/builders/translation_correct_conflict_test.exs b/test/movement/builders/translation_correct_conflict_test.exs new file mode 100644 index 00000000..9b9014f7 --- /dev/null +++ b/test/movement/builders/translation_correct_conflict_test.exs @@ -0,0 +1,32 @@ +defmodule AccentTest.Movement.Builders.TranslationCorrectConflict do + use Accent.RepoCase + + alias Movement.Builders.TranslationCorrectConflict, as: TranslationCorrectConflictBuilder + + alias Accent.{ + Translation + } + + test "builder" do + translation = %Translation{ + key: "a", + proposed_text: "A" + } + + context = + %Movement.Context{} + |> Movement.Context.assign(:text, "My new value") + |> Movement.Context.assign(:translation, translation) + |> TranslationCorrectConflictBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action, :text])) + + assert operations === [ + %{ + key: "a", + text: "My new value", + action: "correct_conflict" + } + ] + end +end diff --git a/test/movement/builders/translation_uncorrect_conflict_test.exs b/test/movement/builders/translation_uncorrect_conflict_test.exs new file mode 100644 index 00000000..b4119e81 --- /dev/null +++ b/test/movement/builders/translation_uncorrect_conflict_test.exs @@ -0,0 +1,30 @@ +defmodule AccentTest.Movement.Builders.TranslationUncorrectConflict do + use Accent.RepoCase + + alias Movement.Builders.TranslationUncorrectConflict, as: TranslationUncorrectConflictBuilder + + alias Accent.{ + Translation + } + + test "builder" do + translation = %Translation{ + key: "a", + proposed_text: "A" + } + + context = + %Movement.Context{} + |> Movement.Context.assign(:translation, translation) + |> TranslationUncorrectConflictBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :action])) + + assert operations === [ + %{ + key: "a", + action: "uncorrect_conflict" + } + ] + end +end diff --git a/test/movement/builders/translation_update_test.exs b/test/movement/builders/translation_update_test.exs new file mode 100644 index 00000000..d756d457 --- /dev/null +++ b/test/movement/builders/translation_update_test.exs @@ -0,0 +1,104 @@ +defmodule AccentTest.Movement.Builders.TranslationUpdate do + use Accent.RepoCase + + alias Accent.Translation + alias Movement.Builders.TranslationUpdate, as: TranslationUpdateBuilder + alias Movement.Context + + test "builder" do + translation = %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A" + } + + context = + %Context{} + |> Context.assign(:text, "Updated!") + |> Context.assign(:translation, translation) + |> TranslationUpdateBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:key, :text, :action])) + + assert operations === [ + %{ + key: "a", + text: "Updated!", + action: "update" + } + ] + end + + test "builder same text" do + translation = %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A" + } + + context = + %Context{} + |> Context.assign(:text, "A") + |> Context.assign(:translation, translation) + |> TranslationUpdateBuilder.build() + + assert context.operations == [] + end + + test "builder value type null to nothing" do + translation = %Translation{ + key: "a", + proposed_text: "null", + corrected_text: "null", + value_type: "null" + } + + context = + %Context{} + |> Context.assign(:text, "Hello!") + |> Context.assign(:translation, translation) + |> TranslationUpdateBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:value_type])) + + assert operations === [%{value_type: ""}] + end + + test "builder value type empty to nothing" do + translation = %Translation{ + key: "a", + proposed_text: "", + corrected_text: "", + value_type: "empty" + } + + context = + %Context{} + |> Context.assign(:text, "Hello!") + |> Context.assign(:translation, translation) + |> TranslationUpdateBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:value_type])) + + assert operations === [%{value_type: ""}] + end + + test "builder value type nothing to empty" do + translation = %Translation{ + key: "a", + proposed_text: "hello!", + corrected_text: "hello!", + value_type: "" + } + + context = + %Context{} + |> Context.assign(:text, "") + |> Context.assign(:translation, translation) + |> TranslationUpdateBuilder.build() + + operations = context.operations |> Enum.map(&Map.take(&1, [:value_type])) + + assert operations === [%{value_type: "empty"}] + end +end diff --git a/test/movement/comparers/merge_force_test.exs b/test/movement/comparers/merge_force_test.exs new file mode 100644 index 00000000..c6d69681 --- /dev/null +++ b/test/movement/comparers/merge_force_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Movement.Comparers.MergeForce do + use ExUnit.Case + doctest Movement.Comparers.MergeForce +end diff --git a/test/movement/comparers/merge_passive_test.exs b/test/movement/comparers/merge_passive_test.exs new file mode 100644 index 00000000..0481e302 --- /dev/null +++ b/test/movement/comparers/merge_passive_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Movement.Comparers.MergePassive do + use ExUnit.Case + doctest Movement.Comparers.MergePassive +end diff --git a/test/movement/comparers/merge_smart_test.exs b/test/movement/comparers/merge_smart_test.exs new file mode 100644 index 00000000..01533a09 --- /dev/null +++ b/test/movement/comparers/merge_smart_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Movement.Comparers.MergeSmart do + use ExUnit.Case + doctest Movement.Comparers.MergeSmart +end diff --git a/test/movement/comparers/sync_test.exs b/test/movement/comparers/sync_test.exs new file mode 100644 index 00000000..ed9ac6e7 --- /dev/null +++ b/test/movement/comparers/sync_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Movement.Comparers.Sync do + use ExUnit.Case + doctest Movement.Comparers.Sync +end diff --git a/test/movement/down_test.exs b/test/movement/down_test.exs new file mode 100644 index 00000000..2e3160e5 --- /dev/null +++ b/test/movement/down_test.exs @@ -0,0 +1,135 @@ +defmodule AccentTest.Migrator.Down do + use Accent.RepoCase + + alias Movement.Migrator + + alias Accent.{ + Repo, + Translation, + PreviousTranslation, + Operation + } + + test ":noop" do + assert {:ok, :noop} == Migrator.down(%{action: "noop"}) + end + + test ":conflict_on_corrected" do + previous_translation = %{ + value_type: "", + corrected_text: "corrected_text", + proposed_text: "proposed_text", + conflicted_text: nil, + conflicted: false, + removed: false + } + + translation = + Repo.insert!(%Translation{ + key: "to_be_in_conflict", + corrected_text: nil, + proposed_text: "new proposed text", + conflicted_text: "corrected_text", + conflicted: true + }) + + Migrator.down( + %Operation{ + action: "conflict_on_corrected", + translation: translation, + previous_translation: PreviousTranslation.from_translation(previous_translation) + } + |> Repo.insert!() + ) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == false + assert new_translation.proposed_text == "proposed_text" + assert new_translation.corrected_text == "corrected_text" + assert new_translation.conflicted_text == nil + end + + test ":conflict_on_proposed" do + previous_translation = %{ + value_type: "", + corrected_text: nil, + proposed_text: "proposed_text", + conflicted_text: nil, + conflicted: true, + removed: false + } + + translation = + Repo.insert!(%Translation{ + key: "to_be_in_proposed", + corrected_text: nil, + proposed_text: "new proposed text", + conflicted_text: "proposed_text", + conflicted: true + }) + + Migrator.down( + %Operation{ + action: "conflict_on_proposed", + translation: translation, + previous_translation: PreviousTranslation.from_translation(previous_translation) + } + |> Repo.insert!() + ) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == true + assert new_translation.proposed_text == "proposed_text" + assert new_translation.corrected_text == nil + assert new_translation.conflicted_text == nil + end + + test ":new" do + translation = + Repo.insert!(%Translation{ + key: "to_be_added_down", + corrected_text: nil, + proposed_text: "new text", + conflicted_text: nil, + conflicted: true + }) + + Migrator.down( + %Operation{ + action: "new", + translation: translation + } + |> Repo.insert!() + ) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.removed == true + end + + test ":remove" do + translation = + Repo.insert!(%Translation{ + value_type: "", + key: "to_be_added_down", + corrected_text: nil, + proposed_text: "new text", + conflicted_text: nil, + conflicted: true + }) + + Migrator.down( + %Operation{ + action: "remove", + translation: translation + } + |> Repo.insert!() + ) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.removed == false + end +end diff --git a/test/movement/persisters/base_test.exs b/test/movement/persisters/base_test.exs new file mode 100644 index 00000000..9929bbb0 --- /dev/null +++ b/test/movement/persisters/base_test.exs @@ -0,0 +1,193 @@ +defmodule AccentTest.Movement.Persisters.Base do + use Accent.RepoCase + + import Ecto.Query + + alias Movement.Persisters.Base, as: BasePersister + + alias Accent.{ + Repo, + Translation, + Revision, + Operation + } + + test "don’t overwrite revision" do + revision = %Revision{} |> Repo.insert!() + revision_two = %Revision{} |> Repo.insert!() + + translation = %Translation{ + key: "a", + conflicted: true, + revision_id: revision.id, + revision: revision + } + + operations = [ + %Movement.Operation{ + action: "new", + key: "a", + text: "B", + translation_id: translation.id, + revision_id: revision.id + } + ] + + %Movement.Context{operations: operations, assigns: %{revision: revision_two}} + |> BasePersister.execute() + + [updated_translation] = Translation |> Repo.all() + [operation] = Operation |> Repo.all() + + assert operation.action == "new" + assert operation.key == "a" + assert operation.text == "B" + assert operation.revision_id == revision.id + + assert updated_translation.revision_id == revision.id + end + + test "persist and execute empty operations" do + {context, operations} = + %Movement.Context{operations: []} + |> BasePersister.execute() + + assert context.operations == [] + assert operations == [] + end + + test "persist and execute operations" do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "update", + key: "a", + text: "B", + translation_id: translation.id + } + ] + + %Movement.Context{operations: operations} + |> BasePersister.execute() + + operation = + Operation + |> where([o], o.batch == false) + |> Repo.one() + + updated_translation = + Translation + |> where([t], t.id == ^translation.id) + |> Repo.one() + + assert operation.action == "update" + assert operation.key == "a" + assert operation.text == "B" + + assert updated_translation.corrected_text == "B" + end + + test "new operation with removed translation" do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + removed: true + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "new", + key: "a", + text: "B", + previous_translation: %{ + "removed" => true + } + } + ] + + %Movement.Context{operations: operations} + |> BasePersister.execute() + + new_translation = + Translation + |> where([t], t.id != ^translation.id) + |> Repo.one() + + assert new_translation.removed == true + end + + test "version operation with source translation" do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + removed: true + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "version_new", + key: "a", + text: "B", + translation_id: translation.id + } + ] + + %Movement.Context{operations: operations} + |> BasePersister.execute() + + new_translation = + Translation + |> where([t], t.id != ^translation.id) + |> Repo.one() + + assert new_translation.source_translation_id == translation.id + end + + test "version operation add operation on source translation" do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + removed: true + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "version_new", + key: "a", + text: "B", + translation_id: translation.id + } + ] + + %Movement.Context{operations: operations} + |> BasePersister.execute() + + new_translation = + Translation + |> where([t], t.id != ^translation.id) + |> Repo.one() + + new_operation = + Operation + |> where([t], t.action == ^"add_to_version") + |> Repo.one() + + assert new_operation.translation_id == new_translation.id + end +end diff --git a/test/movement/persisters/new_slave_test.exs b/test/movement/persisters/new_slave_test.exs new file mode 100644 index 00000000..ebc5c0f1 --- /dev/null +++ b/test/movement/persisters/new_slave_test.exs @@ -0,0 +1,48 @@ +defmodule AccentTest.Movement.Persisters.NewSlave do + use Accent.RepoCase + + require Ecto.Query + + alias Movement.Persisters.NewSlave, as: NewSlavePersister + + alias Accent.{ + Repo, + ProjectCreator, + User, + Language + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> hd() + + {:ok, [project: project, revision: revision]} + end + + test "create revision success", %{project: project, revision: master_revision} do + new_language = Repo.insert!(%Language{name: "French", slug: Ecto.UUID.generate()}) + + {:ok, {context, _}} = + %Movement.Context{assigns: %{project: project, language: new_language, master_revision: master_revision}} + |> NewSlavePersister.persist() + + revision = context.assigns[:revision] + + assert revision.language_id == new_language.id + assert revision.project_id == project.id + assert revision.master_revision_id == master_revision.id + assert revision.master == false + end + + test "create revision error", %{project: project, revision: revision} do + {:error, changeset} = + %Movement.Context{assigns: %{project: project, language: %Language{}, master_revision: revision}} + |> NewSlavePersister.persist() + + assert changeset.errors == [language_id: {"can't be blank", [validation: :required]}] + end +end diff --git a/test/movement/persisters/project_sync_test.exs b/test/movement/persisters/project_sync_test.exs new file mode 100644 index 00000000..03ec67eb --- /dev/null +++ b/test/movement/persisters/project_sync_test.exs @@ -0,0 +1,142 @@ +defmodule AccentTest.Movement.Persisters.ProjectSync do + use Accent.RepoCase + + import Ecto.Query + + alias Accent.{ + Document, + Language, + Operation, + ProjectCreator, + Repo, + Translation, + User + } + + alias Movement.Context + alias Movement.Persisters.ProjectSync, as: ProjectSyncPersister + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [project: project, document: document, revision: revision, user: user]} + end + + test "persist operations", %{project: project, revision: revision, document: document, user: user} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "conflict_on_corrected", + key: "a", + text: "B", + translation_id: translation.id + } + ] + + %Context{operations: operations} + |> Context.assign(:project, project) + |> Context.assign(:document, document) + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, user.id) + |> ProjectSyncPersister.persist() + + batch_operation = + Operation + |> where([o], o.batch == true) + |> Repo.one() + + operation = + Operation + |> where([o], o.batch == false) + |> Repo.one() + + updated_translation = + Translation + |> where([t], t.id == ^translation.id) + |> Repo.one() + + assert batch_operation.project_id == project.id + assert batch_operation.revision_id == revision.id + assert batch_operation.document_id == document.id + assert batch_operation.user_id == user.id + assert batch_operation.action == "sync" + + assert batch_operation.stats == [ + %{ + "action" => "conflict_on_corrected", + "count" => 1 + } + ] + + assert operation.action == "conflict_on_corrected" + assert operation.batch_operation_id == batch_operation.id + assert operation.key == "a" + assert operation.text == "B" + + assert updated_translation.proposed_text == "B" + end + + test "persist document", %{project: project, revision: revision, user: user} do + operations = [ + %Movement.Operation{ + action: "new", + key: "a", + text: "A" + } + ] + + %Context{operations: operations} + |> Context.assign(:project, project) + |> Context.assign(:document, %Document{project_id: project.id, path: "new-doc", format: "json"}) + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, user.id) + |> ProjectSyncPersister.persist() + + new_document = + Document + |> where([d], d.path == "new-doc") + |> where([d], d.format == "json") + |> Repo.one() + + assert new_document.project_id == project.id + end + + test "persist document update", %{project: project, revision: revision, document: document, user: user} do + operations = [ + %Movement.Operation{ + action: "new", + key: "a", + text: "A" + } + ] + + %Context{operations: operations} + |> Context.assign(:project, project) + |> Context.assign(:document, document) + |> Context.assign(:document_update, %{top_of_the_file_comment: "hello", header: "foobar"}) + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, user.id) + |> ProjectSyncPersister.persist() + + new_document = + Document + |> Repo.get(document.id) + + assert new_document.top_of_the_file_comment == "hello" + assert new_document.header == "foobar" + end +end diff --git a/test/movement/persisters/rollback_test.exs b/test/movement/persisters/rollback_test.exs new file mode 100644 index 00000000..3054db17 --- /dev/null +++ b/test/movement/persisters/rollback_test.exs @@ -0,0 +1,215 @@ +defmodule AccentTest.Movement.Persisters.Rollback do + use Accent.RepoCase + + import Ecto.Query + + alias Accent.{ + Document, + Language, + Operation, + PreviousTranslation, + ProjectCreator, + Repo, + Translation, + User + } + + alias Movement.Context + alias Movement.Persisters.Rollback, as: RollbackPersister + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [project: project, document: document, revision: revision, user: user]} + end + + test "persist operations", %{project: project, revision: revision, document: document, user: user} do + translation = + %Translation{ + key: "a", + proposed_text: "A", + conflicted: false, + corrected_text: "Test", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + operation = + %Accent.Operation{ + text: "B", + inserted_at: NaiveDateTime.utc_now(), + updated_at: NaiveDateTime.utc_now(), + action: "correct_conflict", + translation: translation, + previous_translation: PreviousTranslation.from_translation(translation), + translation_id: translation.id, + revision_id: translation.revision_id, + project_id: project.id + } + |> Repo.insert!() + + operations = [ + %Movement.Operation{ + action: "rollback", + translation_id: translation.id, + revision_id: translation.revision_id, + project_id: project.id, + document_id: document.id, + rollbacked_operation_id: operation.id + } + ] + + %Context{operations: operations} + |> Context.assign(:project, project) + |> Context.assign(:operation, operation) + |> Context.assign(:revision, revision) + |> Context.assign(:user_id, user.id) + |> RollbackPersister.persist() + + correct_operation = + Operation + |> where([o], o.action == "correct_conflict") + |> Repo.one() + + rollback_operation = + Operation + |> where([o], o.action == "rollback") + |> Repo.one() + + updated_translation = + Translation + |> where([t], t.id == ^translation.id) + |> Repo.one() + + assert correct_operation.rollbacked == true + + assert rollback_operation.rollbacked_operation_id == correct_operation.id + + assert updated_translation.proposed_text == "A" + assert updated_translation.conflicted == false + end + + test "rollback batch", %{revision: revision} do + translation = + %Translation{ + key: "a", + corrected_text: "B", + conflicted: true, + revision_id: revision.id, + revision: revision + } + |> Repo.insert!() + + %Operation{ + action: "new", + key: "a", + text: "B", + translation_id: translation.id, + revision_id: revision.id + } + |> Repo.insert!() + + batch_operation = + %Operation{ + action: "sync", + batch: true, + revision_id: revision.id + } + |> Repo.insert!() + + operation = + %Operation{ + action: "update", + key: "a", + text: "UPDATED", + previous_translation: PreviousTranslation.from_translation(translation), + translation_id: translation.id, + revision_id: revision.id, + batch_operation_id: batch_operation.id + } + |> Repo.insert!() + + rollback_operation = %Movement.Operation{ + action: "rollback", + key: "a", + batch: true, + previous_translation: PreviousTranslation.from_translation(translation), + rollbacked_operation_id: batch_operation.id + } + + %Context{operations: [rollback_operation], assigns: %{operation: batch_operation}} + |> RollbackPersister.persist() + + updated_operation = Repo.get(Operation, operation.id) + updated_batch_operation = Repo.get(Operation, batch_operation.id) + updated_rollback_operation = Operation |> where(action: ^"rollback") |> Repo.one() + + assert updated_operation.rollbacked == true + assert updated_batch_operation.rollbacked == true + assert updated_rollback_operation.batch == true + assert updated_rollback_operation.rollbacked_operation_id == batch_operation.id + end + + test "rollback rollback does nothing", %{revision: revision} do + translation = + %Translation{ + key: "a", + corrected_text: "B", + conflicted: true, + revision_id: revision.id, + revision: revision + } + |> Repo.insert!() + + %Operation{ + action: "new", + key: "a", + text: "B", + translation_id: translation.id, + revision_id: revision.id + } + |> Repo.insert!() + + operation = + %Operation{ + action: "update", + key: "a", + text: "UPDATED", + previous_translation: PreviousTranslation.from_translation(translation), + translation_id: translation.id, + revision_id: revision.id, + rollbacked: true + } + |> Repo.insert!() + + rollback_operation = + %Operation{ + action: "rollback", + key: "a", + previous_translation: PreviousTranslation.from_translation(translation), + translation_id: translation.id, + revision_id: revision.id, + rollbacked_operation_id: operation.id + } + |> Repo.insert!() + + rollback_rollback_operation = %Movement.Operation{ + action: "rollback", + key: "a", + previous_translation: PreviousTranslation.from_translation(translation) + } + + result = + %Context{operations: [rollback_rollback_operation], assigns: %{operation: rollback_operation}} + |> RollbackPersister.persist() + + assert result == {:error, :cannot_rollback_rollback} + end +end diff --git a/test/movement/translation_comparer_test.exs b/test/movement/translation_comparer_test.exs new file mode 100644 index 00000000..22373065 --- /dev/null +++ b/test/movement/translation_comparer_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Movement.TranslationComparer do + use ExUnit.Case + doctest Movement.TranslationComparer +end diff --git a/test/movement/up_test.exs b/test/movement/up_test.exs new file mode 100644 index 00000000..797913f4 --- /dev/null +++ b/test/movement/up_test.exs @@ -0,0 +1,184 @@ +defmodule AccentTest.Movement.Migrator.Up do + use Accent.RepoCase + + alias Movement.Migrator + + alias Accent.{ + Repo, + Translation, + User, + Revision, + PreviousTranslation, + Operation + } + + test ":noop" do + assert {:ok, :noop} == Migrator.up(%{action: "noop"}) + end + + test ":correct" do + user = %User{} |> Repo.insert!() + revision = %Revision{} |> Repo.insert!() + + translation = + Repo.insert!(%Translation{ + key: "to_be_corrected", + file_comment: "", + corrected_text: nil, + proposed_text: "proposed_text", + conflicted_text: nil, + conflicted: true, + removed: false + }) + + Migrator.up(%Operation{ + id: Ecto.UUID.generate(), + action: "correct_conflict", + translation: translation, + revision_id: revision.id, + user_id: user.id, + text: "new proposed text", + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == false + assert new_translation.proposed_text == "proposed_text" + assert new_translation.corrected_text == "new proposed text" + assert new_translation.conflicted_text == nil + end + + test ":uncorrect" do + translation = + Repo.insert!(%Translation{ + key: "to_be_uncorrected", + file_comment: "", + file_index: 1, + corrected_text: "new proposed text", + proposed_text: "proposed_text", + conflicted_text: "foo", + conflicted: false, + removed: false + }) + + Migrator.up(%Operation{ + action: "uncorrect_conflict", + translation: translation, + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == true + assert new_translation.proposed_text == "proposed_text" + assert new_translation.corrected_text == "new proposed text" + assert new_translation.conflicted_text == "foo" + end + + test ":uncorrect with same corrected and proposed" do + translation = + Repo.insert!(%Translation{ + key: "to_be_uncorrected", + file_comment: "", + file_index: 1, + corrected_text: "proposed_text", + proposed_text: "proposed_text", + conflicted_text: "previous conflicted", + conflicted: false, + removed: false + }) + + Migrator.up(%Operation{ + action: "uncorrect_conflict", + translation: translation, + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == true + assert new_translation.proposed_text == "proposed_text" + assert new_translation.corrected_text == "proposed_text" + assert new_translation.conflicted_text == "previous conflicted" + end + + test ":conflict_on_corrected" do + translation = + Repo.insert!(%Translation{ + key: "to_be_in_conflict", + file_comment: "", + file_index: 1, + corrected_text: "corrected_text", + proposed_text: "proposed_text", + conflicted: false, + removed: false + }) + + Migrator.up(%Operation{ + action: "conflict_on_corrected", + translation: translation, + text: "new proposed text", + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == true + assert new_translation.proposed_text == "new proposed text" + assert new_translation.conflicted_text == translation.corrected_text + end + + test ":remove" do + translation = + Repo.insert!(%Translation{ + key: "to_be_removed", + corrected_text: "corrected_text", + proposed_text: "proposed_text", + conflicted: false, + removed: false + }) + + Migrator.up(%Operation{ + action: "remove", + translation: translation, + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get(Translation, translation.id) + + assert new_translation.removed == true + end + + test ":conflict_on_proposed" do + translation = + Repo.insert!(%Translation{ + value_type: "", + key: "to_be_conflict_on_proposed", + file_comment: "", + file_index: 1, + corrected_text: "corrected_text", + proposed_text: "proposed_text", + conflicted: true, + removed: false + }) + + Migrator.up(%{ + value_type: "", + action: "conflict_on_proposed", + file_comment: "New comment", + file_index: 1, + text: "conflict", + translation: translation, + previous_translation: PreviousTranslation.from_translation(translation) + }) + + new_translation = Repo.get!(Translation, translation.id) + + assert new_translation.conflicted == true + assert new_translation.proposed_text == "conflict" + assert new_translation.corrected_text == "conflict" + assert new_translation.conflicted_text == "corrected_text" + assert new_translation.file_comment == "New comment" + end +end diff --git a/test/plugs/assign_current_user_test.exs b/test/plugs/assign_current_user_test.exs new file mode 100644 index 00000000..da80c39b --- /dev/null +++ b/test/plugs/assign_current_user_test.exs @@ -0,0 +1,46 @@ +defmodule AccentTest.Plugs.AssignCurrentUser do + use Accent.RepoCase + use Plug.Test + + alias Accent.Repo + alias Accent.User + alias Accent.AccessToken + alias Accent.Plugs.AssignCurrentUser + + @user %User{email: "test@test.com"} + @token %AccessToken{revoked_at: nil, token: "1234"} + + defp call_plug(token) do + :get + |> conn("/foo") + |> put_req_header("authorization", "Bearer #{token}") + |> AssignCurrentUser.call(AssignCurrentUser.init([])) + end + + test "assign current_user" do + user = + @user + |> Repo.insert!() + |> Map.put(:permissions, %{}) + + token = Repo.insert!(Map.merge(@token, %{user_id: user.id})) + + assigned_user = + token.token + |> call_plug() + |> Map.get(:assigns) + |> Map.get(:current_user) + + assert assigned_user == user + end + + test "unknown current_user" do + assigned_user = + "123456789" + |> call_plug() + |> Map.get(:assigns) + |> Map.get(:current_user) + + assert assigned_user == nil + end +end diff --git a/test/plugs/bot_params_injector_test.exs b/test/plugs/bot_params_injector_test.exs new file mode 100644 index 00000000..4d488d2f --- /dev/null +++ b/test/plugs/bot_params_injector_test.exs @@ -0,0 +1,65 @@ +defmodule AccentTest.Plugs.BotParamsInjector do + use ExUnit.Case, async: true + use Plug.Test + + alias Accent.User + alias Accent.Plugs.BotParamsInjector + + defp call_plug(user, query_params \\ %{}) do + :get + |> conn("/foo", query_params) + |> assign(:current_user, user) + |> Plug.Conn.fetch_query_params() + |> BotParamsInjector.call(BotParamsInjector.init([])) + end + + test "add project id param when user is bot" do + project_id = + %User{email: "bot", bot: true, permissions: %{"1234" => "bot"}} + |> call_plug() + |> Map.get(:params) + |> Map.get("project_id") + + assert project_id == "1234" + end + + test "add project id variables absinthe param when user is bot" do + project_id = + %User{email: "bot", bot: true, permissions: %{"1234" => "bot"}} + |> call_plug() + |> Map.get(:params) + |> Map.get("variables") + |> Map.get("project_id") + + assert project_id == "1234" + end + + test "add project id variables absinthe param when user is bot and variables ar present" do + project_id = + %User{email: "bot", bot: true, permissions: %{"1234" => "bot"}} + |> call_plug(%{"variables" => %{foo: "bar"}}) + |> Map.get(:params) + |> Map.get("variables") + |> Map.get("project_id") + + assert project_id == "1234" + end + + test "unknown project id param when user is bot" do + updated_conn = + %User{email: "bot", bot: true, permissions: %{}} + |> call_plug() + + assert updated_conn.state == :sent + assert updated_conn.status == 401 + assert updated_conn.resp_body == "Unauthorized" + end + + test "user is not bot" do + updated_conn = + %User{email: "not-a-bot@example.com", bot: false, permissions: %{}} + |> call_plug() + + assert updated_conn.state == :unset + end +end diff --git a/test/plugs/ensure_unlocked_file_operations_test.exs b/test/plugs/ensure_unlocked_file_operations_test.exs new file mode 100644 index 00000000..2ea5cb98 --- /dev/null +++ b/test/plugs/ensure_unlocked_file_operations_test.exs @@ -0,0 +1,30 @@ +defmodule AccentTest.Plugs.EnsureUnlockedFileOperations do + use ExUnit.Case + use Plug.Test + + alias Accent.Plugs.EnsureUnlockedFileOperations + + alias Accent.Project + + test "no halt conn" do + updated_conn = + :get + |> conn("/foo") + |> assign(:project, %Project{locked_file_operations: false}) + |> EnsureUnlockedFileOperations.call(EnsureUnlockedFileOperations.init([])) + + assert updated_conn.state == :unset + end + + test "halt conn" do + updated_conn = + :get + |> conn("/foo") + |> assign(:project, %Project{locked_file_operations: true}) + |> EnsureUnlockedFileOperations.call(EnsureUnlockedFileOperations.init([])) + + assert updated_conn.state == :sent + assert updated_conn.status == 403 + assert updated_conn.resp_body == "File operations are locked" + end +end diff --git a/test/plugs/graphql_context_test.exs b/test/plugs/graphql_context_test.exs new file mode 100644 index 00000000..d0577eff --- /dev/null +++ b/test/plugs/graphql_context_test.exs @@ -0,0 +1,15 @@ +defmodule AccentTest.Plugs.GraphQLContext do + use ExUnit.Case + use Plug.Test + + alias Accent.Plugs.GraphQLContext + + test "assign conn" do + origin_conn = conn(:get, "/foo") + + assert origin_conn == + origin_conn + |> GraphQLContext.call(GraphQLContext.init([])) + |> get_in([Access.key(:private), :absinthe, :context, :conn]) + end +end diff --git a/test/plugs/movement_context_parser_test.exs b/test/plugs/movement_context_parser_test.exs new file mode 100644 index 00000000..3b4985ce --- /dev/null +++ b/test/plugs/movement_context_parser_test.exs @@ -0,0 +1,190 @@ +defmodule AccentTest.Plugs.MovementContextParser do + use Accent.RepoCase + use Plug.Test + alias Accent.Plugs.MovementContextParser + + alias Accent.{ + Repo, + ProjectCreator, + Language, + User, + Document + } + + def file(filename \\ "simple.json") do + %Plug.Upload{content_type: "application/json", filename: filename, path: "test/support/formatter/json/simple.json"} + end + + def file_with_header do + %Plug.Upload{content_type: "plain/text", filename: "simple.gettext", path: "test/support/formatter/gettext/simple.po"} + end + + def invalid_file do + %Plug.Upload{content_type: "application/json", filename: "simple.json", path: "test/support/invalid_file.json"} + end + + @user %User{email: "test@test.com"} + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> Enum.at(0) + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [project: project, document: document, revision: revision, language: language, user: user]} + end + + test "with no params" do + conn = + :get + |> conn("/foo") + |> MovementContextParser.call([]) + + assert conn.status == 422 + assert conn.resp_body == "file, language and document_format are required" + assert conn.state == :sent + end + + test "with missing file" do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "json", language: "fr"}) + |> MovementContextParser.call([]) + + assert conn.status == 422 + assert conn.resp_body == "file, language and document_format are required" + assert conn.state == :sent + end + + test "fetch document path with only file param", %{project: project} do + conn = + :get + |> conn("/foo", %{document_format: "json", file: file("foo.json"), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + assert conn.assigns[:document_path] == "foo" + assert conn.state == :unset + end + + test "fetch document parser", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "json", file: file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + assert conn.assigns[:document_parser] == (&Langue.Formatter.Json.Parser.parse/1) + assert conn.state == :unset + end + + test "fetch invalid document parser", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "UNKOWN", file: file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + assert conn.resp_body == "document_format is invalid" + assert conn.status == 422 + assert conn.state == :sent + end + + test "fetch document render", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "json", file: file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + context = + conn.assigns + |> Map.get(:movement_context) + + assert context.render == File.read!(file().path) + assert conn.state == :unset + end + + test "fetch new document resource", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "hello.json", document_format: "json", file: file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + context = + conn.assigns + |> Map.get(:movement_context) + + assert context.assigns[:document] == %Document{path: "hello", format: "json", project_id: project.id} + assert conn.state == :unset + end + + test "assign document top_of_the_file_comment and header", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "hello.po", document_format: "gettext", file: file_with_header(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + context = + conn.assigns + |> Map.get(:movement_context) + + assert context.assigns[:document] == %Document{ + project_id: project.id, + path: "hello", + format: "gettext", + top_of_the_file_comment: + "## Do not add, change, or remove `msgid`s manually here as\n## they're tied to the ones in the corresponding POT file\n## (with the same domain).\n##\n## Use `mix gettext.extract --merge` or `mix gettext.merge`\n## to merge POT files into PO files.", + header: "\nLanguage: fr\n" + } + + assert conn.state == :unset + end + + test "fetch existing document resource", %{project: project, language: language, document: document} do + conn = + :get + |> conn("/foo", %{document_path: document.path, document_format: "json", file: file(), language: language.slug}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + context = + conn.assigns + |> Map.get(:movement_context) + + assert context.assigns[:document] == document + assert conn.state == :unset + end + + test "fetch entries", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "json", file: file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + context = + conn.assigns + |> Map.get(:movement_context) + + assert context.entries == [ + %Langue.Entry{comment: "", index: 1, key: "test", value: "F"}, + %Langue.Entry{comment: "", index: 2, key: "test2", value: "D"}, + %Langue.Entry{comment: "", index: 3, key: "test3", value: "New history please"} + ] + end + + test "invalid file", %{project: project} do + conn = + :get + |> conn("/foo", %{document_path: "test.json", document_format: "json", file: invalid_file(), language: "fr"}) + |> assign(:project, project) + |> MovementContextParser.call([]) + + assert conn.state == :sent + assert conn.status == 422 + assert conn.resp_body == "file cannot be parsed" + end +end diff --git a/test/pretty_float_test.exs b/test/pretty_float_test.exs new file mode 100644 index 00000000..6eccae14 --- /dev/null +++ b/test/pretty_float_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.PrettyFloat do + use ExUnit.Case, async: true + doctest Accent.PrettyFloat +end diff --git a/test/schemas/document_format_test.exs b/test/schemas/document_format_test.exs new file mode 100644 index 00000000..b61ab043 --- /dev/null +++ b/test/schemas/document_format_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.DocumentFormat do + use ExUnit.Case, async: true + doctest Accent.DocumentFormat +end diff --git a/test/schemas/previous_translation_test.exs b/test/schemas/previous_translation_test.exs new file mode 100644 index 00000000..ff251328 --- /dev/null +++ b/test/schemas/previous_translation_test.exs @@ -0,0 +1,5 @@ +defmodule AccentTest.PreviousTranslation do + use ExUnit.Case + + doctest Accent.PreviousTranslation +end diff --git a/test/schemas/role_test.exs b/test/schemas/role_test.exs new file mode 100644 index 00000000..272e3f18 --- /dev/null +++ b/test/schemas/role_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Role do + use ExUnit.Case, async: true + doctest Accent.Role +end diff --git a/test/schemas/user_test.exs b/test/schemas/user_test.exs new file mode 100644 index 00000000..91f84eb9 --- /dev/null +++ b/test/schemas/user_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.User do + use ExUnit.Case, async: true + doctest Accent.User +end diff --git a/test/scopes/comment_test.exs b/test/scopes/comment_test.exs new file mode 100644 index 00000000..111409b6 --- /dev/null +++ b/test/scopes/comment_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Comment do + use ExUnit.Case, async: true + doctest Accent.Scopes.Comment +end diff --git a/test/scopes/document_test.exs b/test/scopes/document_test.exs new file mode 100644 index 00000000..65afbc13 --- /dev/null +++ b/test/scopes/document_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Document do + use ExUnit.Case, async: true + doctest Accent.Scopes.Document +end diff --git a/test/scopes/language_test.exs b/test/scopes/language_test.exs new file mode 100644 index 00000000..457b8a7d --- /dev/null +++ b/test/scopes/language_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Language do + use ExUnit.Case, async: true + doctest Accent.Scopes.Language +end diff --git a/test/scopes/operation_test.exs b/test/scopes/operation_test.exs new file mode 100644 index 00000000..53c748e1 --- /dev/null +++ b/test/scopes/operation_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Operation do + use ExUnit.Case, async: true + doctest Accent.Scopes.Operation +end diff --git a/test/scopes/project_test.exs b/test/scopes/project_test.exs new file mode 100644 index 00000000..d2974290 --- /dev/null +++ b/test/scopes/project_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Project do + use ExUnit.Case, async: true + doctest Accent.Scopes.Project +end diff --git a/test/scopes/revision_test.exs b/test/scopes/revision_test.exs new file mode 100644 index 00000000..91e37e3f --- /dev/null +++ b/test/scopes/revision_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Revision do + use ExUnit.Case, async: true + doctest Accent.Scopes.Revision +end diff --git a/test/scopes/translation_test.exs b/test/scopes/translation_test.exs new file mode 100644 index 00000000..6ea71612 --- /dev/null +++ b/test/scopes/translation_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Translation do + use ExUnit.Case, async: true + doctest Accent.Scopes.Translation +end diff --git a/test/scopes/version_test.exs b/test/scopes/version_test.exs new file mode 100644 index 00000000..71bd80ea --- /dev/null +++ b/test/scopes/version_test.exs @@ -0,0 +1,4 @@ +defmodule AccentTest.Scopes.Version do + use ExUnit.Case, async: true + doctest Accent.Scopes.Version +end diff --git a/test/services/collaborator_creator_test.exs b/test/services/collaborator_creator_test.exs new file mode 100644 index 00000000..001a92d8 --- /dev/null +++ b/test/services/collaborator_creator_test.exs @@ -0,0 +1,55 @@ +defmodule AccentTest.CollaboratorCreator do + use Accent.RepoCase + + alias Accent.{Repo, User, Project, CollaboratorCreator} + + test "create unknown email" do + email = "test@test.com" + project = %Project{name: "com"} |> Repo.insert!() + assigner = %User{email: "lol@test.com"} |> Repo.insert!() + role = "admin" + + {:ok, collaborator} = CollaboratorCreator.create(%{"email" => email, "assigner_id" => assigner.id, "role" => role, "project_id" => project.id}) + + assert collaborator.email === email + assert collaborator.assigner_id === assigner.id + assert collaborator.role === role + end + + test "create known email" do + email = "test@test.com" + project = %Project{name: "com"} |> Repo.insert!() + user = %User{email: email} |> Repo.insert!() + assigner = %User{email: "lol@test.com"} |> Repo.insert!() + role = "admin" + + {:ok, collaborator} = CollaboratorCreator.create(%{"email" => email, "assigner_id" => assigner.id, "role" => role, "project_id" => project.id}) + + assert collaborator.email === email + assert collaborator.user_id === user.id + assert collaborator.assigner_id === assigner.id + assert collaborator.role === role + end + + test "create invalid role" do + email = "test@test.com" + project = %Project{name: "com"} |> Repo.insert!() + assigner = %User{email: "lol@test.com"} |> Repo.insert!() + role = "test123" + + {:error, collaborator} = CollaboratorCreator.create(%{"email" => email, "assigner_id" => assigner.id, "role" => role, "project_id" => project.id}) + + assert collaborator.errors === [role: {"is invalid", [validation: :inclusion]}] + end + + test "create with insensitive email" do + email = "TEST@test.com" + project = %Project{name: "com"} |> Repo.insert!() + assigner = %User{email: "lol@test.com"} |> Repo.insert!() + role = "admin" + + {:ok, collaborator} = CollaboratorCreator.create(%{"email" => email, "assigner_id" => assigner.id, "role" => role, "project_id" => project.id}) + + assert collaborator.email === "test@test.com" + end +end diff --git a/test/services/collaborator_normalizer_test.exs b/test/services/collaborator_normalizer_test.exs new file mode 100644 index 00000000..a20e2024 --- /dev/null +++ b/test/services/collaborator_normalizer_test.exs @@ -0,0 +1,66 @@ +defmodule AccentTest.CollaboratorNormalizer do + use Accent.RepoCase + require Ecto.Query + + alias Accent.{Repo, Project, User, Collaborator, UserRemote.CollaboratorNormalizer} + + test "create with many collaborations" do + project = %Project{name: "Ha"} |> Repo.insert!() + project2 = %Project{name: "Oh"} |> Repo.insert!() + assigner = %User{email: "assigner@test.com"} |> Repo.insert!() + + collaborators = [ + %Collaborator{role: "admin", project_id: project.id, assigner_id: assigner.id}, + %Collaborator{role: "developer", project_id: project2.id, assigner_id: assigner.id} + ] + + collaborator_ids = + collaborators + |> Enum.map(&Collaborator.create_changeset(&1, %{"email" => "test@test.com"})) + |> Enum.map(&Repo.insert!/1) + |> Enum.map(&Map.get(&1, :id)) + + new_user = %User{email: "test@test.com"} |> Repo.insert!() + + :ok = CollaboratorNormalizer.normalize(new_user) + + new_collaborators = Collaborator |> Ecto.Query.where([c], c.id in ^collaborator_ids) |> Repo.all() + + assert new_collaborators |> Enum.map(&Map.get(&1, :user_id)) |> Enum.uniq() === [new_user.id] + end + + test "create with case insensitive email" do + project = %Project{name: "Ha"} |> Repo.insert!() + project2 = %Project{name: "Oh"} |> Repo.insert!() + assigner = %User{email: "assigner@test.com"} |> Repo.insert!() + + collaborators = [ + %Collaborator{role: "admin", project_id: project.id, assigner_id: assigner.id}, + %Collaborator{role: "developer", project_id: project2.id, assigner_id: assigner.id} + ] + + collaborator_ids = + collaborators + |> Enum.map(&Collaborator.create_changeset(&1, %{"email" => "test@test.com"})) + |> Enum.map(&Repo.insert!/1) + |> Enum.map(&Map.get(&1, :id)) + + new_user = %User{email: "Test@test.com"} |> Repo.insert!() + + :ok = CollaboratorNormalizer.normalize(new_user) + + new_collaborators = Collaborator |> Ecto.Query.where([c], c.id in ^collaborator_ids) |> Repo.all() + + assert new_collaborators |> Enum.map(&Map.get(&1, :user_id)) |> Enum.uniq() === [new_user.id] + end + + test "create without collaborations" do + new_user = %User{email: "Test@test.com"} |> Repo.insert!() + + :ok = CollaboratorNormalizer.normalize(new_user) + + new_collaborators = Collaborator |> Repo.all() + + assert new_collaborators === [] + end +end diff --git a/test/services/collaborator_updater_test.exs b/test/services/collaborator_updater_test.exs new file mode 100644 index 00000000..3d0d79e4 --- /dev/null +++ b/test/services/collaborator_updater_test.exs @@ -0,0 +1,19 @@ +defmodule AccentTest.CollaboratorUpdater do + use Accent.RepoCase + + alias Accent.{Repo, User, Project, Collaborator, CollaboratorUpdater} + + test "update" do + email = "test@test.com" + project = %Project{name: "com"} |> Repo.insert!() + assigner = %User{email: "lol@test.com"} |> Repo.insert!() + role = "admin" + collaborator = %Collaborator{role: role, assigner: assigner, project: project, email: email} |> Repo.insert!() + + {:ok, updated_collaborator} = CollaboratorUpdater.update(collaborator, %{"role" => "reviewer"}) + + assert updated_collaborator.email === collaborator.email + assert updated_collaborator.assigner_id === collaborator.assigner.id + assert updated_collaborator.role === "reviewer" + end +end diff --git a/test/services/operation_batcher_test.exs b/test/services/operation_batcher_test.exs new file mode 100644 index 00000000..1a2a5cad --- /dev/null +++ b/test/services/operation_batcher_test.exs @@ -0,0 +1,202 @@ +defmodule AccentTest.OperationBatcher do + use Accent.RepoCase + + require Ecto.Query + alias Ecto.Query + + alias Accent.{ + Operation, + OperationBatcher, + Repo, + Revision, + Translation, + User + } + + setup do + user = %User{} |> Repo.insert!() + revision = %Revision{} |> Repo.insert!() + + translation_one = + %Translation{ + key: "a", + conflicted: true, + revision_id: revision.id, + revision: revision + } + |> Repo.insert!() + + translation_two = + %Translation{ + key: "b", + conflicted: true, + revision_id: revision.id, + revision: revision + } + |> Repo.insert!() + + [user: user, revision: revision, translations: [translation_one, translation_two]] + end + + test "create batch with close operations", %{user: user, revision: revision, translations: [translation_one, _]} do + operations = + [ + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() + } + ] + |> Enum.map(&Repo.insert!/1) + |> Enum.map(&Map.get(&1, :id)) + + operation = + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() + } + |> Repo.insert!() + + batch_responses = OperationBatcher.batch(operation) + + updated_operations = + Operation + |> Query.where([o], o.id in ^operations) + |> Query.or_where(id: ^operation.id) + |> Repo.all() + |> Enum.map(&Map.get(&1, :batch_operation_id)) + |> Enum.uniq() + + batch_operation = Repo.get_by(Operation, action: "batch_correct_conflict") + + assert batch_responses == {2, nil} + assert updated_operations == [batch_operation.id] + assert batch_operation.stats == [%{"count" => 2, "action" => "correct_conflict"}] + end + + test "create batch with close operations but some not so close with existing batch operation", %{user: user, revision: revision, translations: [translation_one, translation_two]} do + batch_operation = + %Operation{ + action: "batch_correct_conflict", + user_id: user.id, + revision_id: revision.id, + stats: [%{"count" => 2, "action" => "correct_conflict"}], + inserted_at: DateTime.utc_now() |> DateTime.to_naive() |> NaiveDateTime.add(-960, :second) + } + |> Repo.insert!() + + operations = + [ + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + batch_operation_id: batch_operation.id, + inserted_at: DateTime.utc_now() |> DateTime.to_naive() |> NaiveDateTime.add(-960, :second) + }, + %Operation{ + action: "correct_conflict", + key: "b", + text: "C", + translation_id: translation_two.id, + user_id: user.id, + revision_id: revision.id, + batch_operation_id: batch_operation.id, + inserted_at: DateTime.utc_now() + } + ] + |> Enum.map(&Repo.insert!/1) + |> Enum.map(&Map.get(&1, :id)) + + operation = + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() + } + |> Repo.insert!() + + batch_responses = Accent.OperationBatcher.batch(operation) + + updated_operations = + Operation + |> Query.where([o], o.id in ^operations) + |> Query.or_where(id: ^operation.id) + |> Repo.all() + |> Enum.map(&Map.get(&1, :batch_operation_id)) + |> Enum.uniq() + + batch_operation = Repo.get_by(Operation, action: "batch_correct_conflict") + + assert batch_responses == {2, nil} + assert updated_operations == [batch_operation.id] + assert batch_operation.stats == [%{"count" => 3, "action" => "correct_conflict"}] + end + + test "don’t create batch with operations happening in more than 60 minutes", %{user: user, revision: revision, translations: [translation_one, translation_two]} do + operations = + [ + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() |> DateTime.to_naive() |> NaiveDateTime.add(-3960, :second) + }, + %Operation{ + action: "correct_conflict", + key: "b", + text: "C", + translation_id: translation_two.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() |> DateTime.to_naive() |> NaiveDateTime.add(-3960, :second) + } + ] + |> Enum.map(&Repo.insert!/1) + |> Enum.map(&Map.get(&1, :id)) + + operation = + %Operation{ + action: "correct_conflict", + key: "a", + text: "B", + translation_id: translation_one.id, + user_id: user.id, + revision_id: revision.id, + inserted_at: DateTime.utc_now() + } + |> Repo.insert!() + + batch_responses = Accent.OperationBatcher.batch(operation) + + updated_operations = + Operation + |> Query.where([o], o.id in ^operations) + |> Query.or_where(id: ^operation.id) + |> Repo.all() + |> Enum.map(&Map.get(&1, :batch_operation_id)) + |> Enum.uniq() + + assert batch_responses == {0, nil} + assert updated_operations == [nil] + end +end diff --git a/test/services/project_creator_test.exs b/test/services/project_creator_test.exs new file mode 100644 index 00000000..f9d05311 --- /dev/null +++ b/test/services/project_creator_test.exs @@ -0,0 +1,45 @@ +defmodule AccentTest.ProjectCreator do + use Accent.RepoCase + require Ecto.Query + + alias Accent.{Repo, Language, User, ProjectCreator} + + test "create with language and user" do + language = %Language{name: "french"} |> Repo.insert!() + user = %User{email: "lol@test.com"} |> Repo.insert!() + params = %{"name" => "OK", "language_id" => language.id} + + {:ok, project} = ProjectCreator.create(params: params, user: user) + + assert project.name === "OK" + assert Enum.at(project.revisions, 0).project_id === project.id + assert Enum.at(project.revisions, 0).language_id === language.id + assert Enum.at(project.revisions, 0).master === true + end + + test "create owner collaborator" do + language = %Language{name: "french"} |> Repo.insert!() + user = %User{email: "lol@test.com"} |> Repo.insert!() + params = %{"name" => "OK", "language_id" => language.id} + + {:ok, project} = ProjectCreator.create(params: params, user: user) + owner_collaborator = project |> Ecto.assoc(:collaborators) |> Ecto.Query.where([c], c.role == "owner") |> Repo.one() + + assert owner_collaborator.user_id == user.id + end + + test "create bot collaborator" do + language = %Language{name: "french"} |> Repo.insert!() + user = %User{email: "lol@test.com"} |> Repo.insert!() + params = %{"name" => "OK", "language_id" => language.id} + + {:ok, project} = ProjectCreator.create(params: params, user: user) + bot_collaborator = project |> Ecto.assoc(:collaborators) |> Ecto.Query.where([c], c.role == "bot") |> Repo.one() + bot_user = Repo.preload(bot_collaborator, :user).user + bot_access = Repo.preload(bot_collaborator, user: :access_tokens).user.access_tokens |> Enum.at(0) + + refute is_nil(bot_collaborator.user_id) + refute is_nil(bot_access.token) + assert bot_user.bot === true + end +end diff --git a/test/services/project_deleter_test.exs b/test/services/project_deleter_test.exs new file mode 100644 index 00000000..463db58d --- /dev/null +++ b/test/services/project_deleter_test.exs @@ -0,0 +1,17 @@ +defmodule AccentTest.ProjectDeleter do + use Accent.RepoCase + require Ecto.Query + + alias Accent.{Repo, Project, Collaborator, ProjectDeleter} + + test "create with language and user" do + project = %Project{name: "french"} |> Repo.insert!() + collaborator = %Collaborator{project_id: project.id} |> Repo.insert!() + + assert Repo.all(Ecto.assoc(project, :collaborators)) === [collaborator] + + {:ok, project} = ProjectDeleter.delete(project: project) + + assert Repo.all(Ecto.assoc(project, :collaborators)) === [] + end +end diff --git a/test/services/revision_deleter_test.exs b/test/services/revision_deleter_test.exs new file mode 100644 index 00000000..c14f4dc8 --- /dev/null +++ b/test/services/revision_deleter_test.exs @@ -0,0 +1,45 @@ +defmodule AccentTest.RevisionDeleter do + use Accent.RepoCase + require Ecto.Query + + alias Accent.{Repo, Language, Revision, Project, Operation, Translation, RevisionDeleter} + + setup do + project = %Project{name: "My project"} |> Repo.insert!() + french_language = %Language{name: "french"} |> Repo.insert!() + english_language = %Language{name: "english"} |> Repo.insert!() + + master_revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + slave_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: master_revision.id} |> Repo.insert!() + + {:ok, [master_revision: master_revision, slave_revision: slave_revision]} + end + + test "delete slave", %{slave_revision: revision} do + {:ok, _revision} = RevisionDeleter.delete(revision: revision) + + assert Repo.get(Revision, revision.id) == nil + end + + test "delete master", %{master_revision: revision} do + error = RevisionDeleter.delete(revision: revision) + + assert error == {:error, "can't delete master language"} + end + + test "delete operations", %{slave_revision: revision} do + operation = %Operation{action: "new", key: "a", revision_id: revision.id} |> Repo.insert!() + + {:ok, _revision} = RevisionDeleter.delete(revision: revision) + + assert Repo.get(Operation, operation.id) == nil + end + + test "delete translations", %{slave_revision: revision} do + translation = %Translation{key: "a", revision_id: revision.id} |> Repo.insert!() + + {:ok, _revision} = RevisionDeleter.delete(revision: revision) + + assert Repo.get(Translation, translation.id) == nil + end +end diff --git a/test/services/revision_master_promoter_test.exs b/test/services/revision_master_promoter_test.exs new file mode 100644 index 00000000..713fb05a --- /dev/null +++ b/test/services/revision_master_promoter_test.exs @@ -0,0 +1,34 @@ +defmodule AccentTest.RevisionMasterPromoter do + use Accent.RepoCase + require Ecto.Query + + alias Accent.{Repo, Language, Revision, Project, RevisionMasterPromoter} + + setup do + french_language = %Language{name: "french"} |> Repo.insert!() + english_language = %Language{name: "english"} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + master_revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + slave_revision = %Revision{language_id: english_language.id, project_id: project.id, master: false, master_revision_id: master_revision.id} |> Repo.insert!() + + {:ok, [master_revision: master_revision, slave_revision: slave_revision]} + end + + test "promote slave", %{slave_revision: revision, master_revision: master_revision} do + {:ok, revision} = RevisionMasterPromoter.promote(revision: revision) + + old_master_revision = Repo.get(Revision, master_revision.id) + + assert old_master_revision.master == false + assert old_master_revision.master_revision_id == revision.id + assert revision.master == true + assert revision.master_revision_id == nil + end + + test "promote master", %{master_revision: revision} do + {:error, changeset} = RevisionMasterPromoter.promote(revision: revision) + + assert changeset.errors == [master: {"invalid", []}] + end +end diff --git a/test/services/translations_renderer_test.exs b/test/services/translations_renderer_test.exs new file mode 100644 index 00000000..10f2e093 --- /dev/null +++ b/test/services/translations_renderer_test.exs @@ -0,0 +1,119 @@ +defmodule AccentTest.TranslationsRenderer do + use Accent.RepoCase + + alias Accent.{ + Repo, + User, + ProjectCreator, + Language, + Translation, + Document, + TranslationsRenderer + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{name: "My project", language_id: language.id}, user: user) + + revision = + project + |> Repo.preload(:revisions) + |> Map.get(:revisions) + |> Enum.at(0) + |> Repo.preload(:language) + + {:ok, [project: project, revision: revision]} + end + + test "render json with filename", %{project: project, revision: revision} do + document = Repo.insert!(%Document{project_id: project.id, path: "my-test", format: "json"}) + + translation = + %Translation{ + key: "a", + proposed_text: "B", + corrected_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + %{render: render} = + TranslationsRenderer.render(%{ + translations: [translation], + document_format: document.format, + document_locale: revision.language.slug + }) + + expected_render = """ + { + "a": "A" + } + """ + + assert render == expected_render + end + + test "render json with runtime error", %{project: project, revision: revision} do + document = Repo.insert!(%Document{project_id: project.id, path: "my-test", format: "json"}) + + translations = + [ + %Translation{ + key: "a.nested.foo", + proposed_text: "B", + corrected_text: "A", + revision_id: revision.id, + document_id: document.id + }, + %Translation{ + key: "a.nested", + proposed_text: "C", + corrected_text: "D", + revision_id: revision.id, + document_id: document.id + } + ] + |> Enum.map(&Repo.insert!/1) + + %{render: render} = + TranslationsRenderer.render(%{ + translations: translations, + document_format: document.format, + document_locale: revision.language.slug + }) + + assert render == "" + end + + test "render rails with locale", %{project: project, revision: revision} do + document = Repo.insert!(%Document{project_id: project.id, path: "my-test", format: "rails_yml"}) + + translation = + %Translation{ + key: "a", + proposed_text: "A", + corrected_text: "A", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + %{render: render} = + TranslationsRenderer.render(%{ + translations: [translation], + document_format: document.format, + document_locale: "fr" + }) + + expected_render = """ + "fr": + "a": "A" + """ + + assert render == expected_render + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 00000000..1f61cac2 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,43 @@ +defmodule Accent.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common datastructures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + alias Ecto.{Adapters.SQL.Sandbox} + alias Phoenix.ConnTest + alias Accent.Endpoint + alias Accent.Repo + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + import Accent.Router.Helpers + + # The default endpoint for testing + @endpoint Endpoint + end + end + + setup tags do + :ok = Sandbox.checkout(Repo) + + unless tags[:async] do + Sandbox.mode(Repo, {:shared, self()}) + end + + {:ok, conn: ConnTest.build_conn()} + end +end diff --git a/test/support/formatter/gettext/simple.po b/test/support/formatter/gettext/simple.po new file mode 100644 index 00000000..be7967c0 --- /dev/null +++ b/test/support/formatter/gettext/simple.po @@ -0,0 +1,17 @@ +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: fr\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "ne peut être vide" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "est déjà pris" diff --git a/test/support/formatter/json/simple.json b/test/support/formatter/json/simple.json new file mode 100644 index 00000000..4d9bc406 --- /dev/null +++ b/test/support/formatter/json/simple.json @@ -0,0 +1,5 @@ +{ + "test": "F", + "test2": "D", + "test3": "New history please" +} diff --git a/test/support/invalid_file.json b/test/support/invalid_file.json new file mode 100644 index 00000000..9e426e0a --- /dev/null +++ b/test/support/invalid_file.json @@ -0,0 +1,5 @@ +{ + "foo": "bar" + "missing": "Comma" + "everywhere": "!", +} diff --git a/test/support/mocks.ex b/test/support/mocks.ex new file mode 100644 index 00000000..256b9ffa --- /dev/null +++ b/test/support/mocks.ex @@ -0,0 +1 @@ +Mox.defmock(Accent.Hook.BroadcasterMock, for: Accent.Hook.Broadcaster) diff --git a/test/support/repo_case.ex b/test/support/repo_case.ex new file mode 100644 index 00000000..933a6cba --- /dev/null +++ b/test/support/repo_case.ex @@ -0,0 +1,13 @@ +defmodule Accent.RepoCase do + use ExUnit.CaseTemplate, async: true + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Accent.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Accent.Repo, {:shared, self()}) + end + + :ok + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 00000000..d103f7d4 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,34 @@ +defmodule Accent.FormatterTestHelper do + def test_parse(variant, parser) do + context = %Langue.Formatter.SerializerResult{render: variant.render} |> parser.parse + + {variant.entries, context.entries} + end + + def test_serialize(variant, serializer, locale \\ "fr") do + context = + %Langue.Formatter.ParserResult{ + entries: variant.entries, + locale: locale, + top_of_the_file_comment: variant.top_of_the_file_comment, + header: variant.header + } + |> serializer.serialize + + {variant.render, context.render} + end +end + +defmodule Langue.Expectation.Case do + defmacro __using__(_) do + quote do + def top_of_the_file_comment, do: "" + def header, do: "" + + defoverridable top_of_the_file_comment: 0, header: 0 + end + end +end + +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Accent.Repo, :manual) diff --git a/test/web/controllers/authentication_controller_test.exs b/test/web/controllers/authentication_controller_test.exs new file mode 100644 index 00000000..7de6fe59 --- /dev/null +++ b/test/web/controllers/authentication_controller_test.exs @@ -0,0 +1,31 @@ +defmodule AccentTest.AuthenticationController do + use Accent.ConnCase + + test "create responds with error when invalid params", %{conn: conn} do + response = + conn + |> post(authentication_path(conn, :create)) + |> json_response(401) + + assert response == %{"error" => "Invalid params"} + end + + test "create responds with authenticated user", %{conn: conn} do + response = + conn + |> post(authentication_path(conn, :create), %{uid: "test@example.com", provider: "dummy"}) + |> json_response(200) + + assert get_in(response, ["user", "email"]) == "test@example.com" + assert get_in(response, ["token"]) != nil + end + + test "create responds with error on unkown provider", %{conn: conn} do + response = + conn + |> post(authentication_path(conn, :create), %{uid: "test@example.com", provider: "test"}) + |> json_response(401) + + assert response == %{"error" => %{"provider" => "unknown"}} + end +end diff --git a/test/web/controllers/badge_controller_test.exs b/test/web/controllers/badge_controller_test.exs new file mode 100644 index 00000000..febf3a72 --- /dev/null +++ b/test/web/controllers/badge_controller_test.exs @@ -0,0 +1,64 @@ +defmodule AccentTest.BadgeController do + use Accent.ConnCase, async: false + + import Mock + + alias Accent.{ + Repo, + Project + } + + defp behave_like_valid_response(response) do + assert response.status == 200 + assert response.resp_body == "" + assert get_resp_header(response, "content-type") == ["image/svg+xml; charset=utf-8"] + end + + setup do + id = Ecto.UUID.generate() + project = %Project{id: id, name: "project"} |> Repo.insert!() + badge_generate_mock = [generate: fn _, _ -> {:ok, ""} end] + + {:ok, %{project: project, badge_generate_mock: badge_generate_mock}} + end + + test "percentage_reviewed_count", %{conn: conn, project: project, badge_generate_mock: badge_generate_mock} do + with_mock Accent.BadgeGenerator, badge_generate_mock do + response = + conn + |> get(badge_path(conn, :percentage_reviewed_count, project)) + + behave_like_valid_response(response) + end + end + + test "translations_count", %{conn: conn, project: project, badge_generate_mock: badge_generate_mock} do + with_mock Accent.BadgeGenerator, badge_generate_mock do + response = + conn + |> get(badge_path(conn, :translations_count, project)) + + behave_like_valid_response(response) + end + end + + test "reviewed_count", %{conn: conn, project: project, badge_generate_mock: badge_generate_mock} do + with_mock Accent.BadgeGenerator, badge_generate_mock do + response = + conn + |> get(badge_path(conn, :reviewed_count, project)) + + behave_like_valid_response(response) + end + end + + test "conflicts", %{conn: conn, project: project, badge_generate_mock: badge_generate_mock} do + with_mock Accent.BadgeGenerator, badge_generate_mock do + response = + conn + |> get(badge_path(conn, :conflicts_count, project)) + + behave_like_valid_response(response) + end + end +end diff --git a/test/web/controllers/error_controller_test.exs b/test/web/controllers/error_controller_test.exs new file mode 100644 index 00000000..8e31af88 --- /dev/null +++ b/test/web/controllers/error_controller_test.exs @@ -0,0 +1,19 @@ +defmodule AccentTest.ErrorController do + use Accent.ConnCase + + test "unauthorized", %{conn: conn} do + conn = Accent.ErrorController.handle_unauthorized(conn) + + assert conn.status == 401 + assert conn.resp_body == "Unauthorized" + assert conn.state == :sent + end + + test "not_found", %{conn: conn} do + conn = Accent.ErrorController.handle_not_found(conn) + + assert conn.status == 404 + assert conn.resp_body == "Not found" + assert conn.state == :sent + end +end diff --git a/test/web/controllers/export_controller_test.exs b/test/web/controllers/export_controller_test.exs new file mode 100644 index 00000000..349ea070 --- /dev/null +++ b/test/web/controllers/export_controller_test.exs @@ -0,0 +1,196 @@ +defmodule AccentTest.ExportController do + use Accent.ConnCase + + alias Accent.{ + Repo, + Project, + Translation, + Revision, + Document, + Version, + User, + Language + } + + @user %User{email: "test@test.com"} + + setup do + user = Repo.insert!(@user) + french_language = %Language{name: "french", slug: Ecto.UUID.generate()} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + + {:ok, [user: user, project: project, revision: revision, language: french_language]} + end + + test "export inline", %{conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + + params = %{inline_render: true, project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert get_resp_header(response, "content-type") == ["text/plain"] + + assert response.resp_body == """ + { + "ok": "bar" + } + """ + end + + test "export basic", %{conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + + params = %{project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert get_resp_header(response, "content-disposition") == ["inline; filename=\"#{document.path}\""] + + assert response.resp_body == """ + { + "ok": "bar" + } + """ + end + + test "export unknown language for the project", %{conn: conn, project: project} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + language = %Language{name: "chinese", slug: Ecto.UUID.generate()} |> Repo.insert!() + + params = %{project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.status == 404 + end + + test "export document", %{conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + other_document = %Document{project_id: project.id, path: "test3", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", corrected_text: "foo", proposed_text: "foo", document_id: other_document.id} |> Repo.insert!() + + params = %{project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert get_resp_header(response, "content-disposition") == ["inline; filename=\"#{document.path}\""] + + assert response.resp_body == """ + { + "ok": "bar" + } + """ + end + + test "export unknown document", %{conn: conn, project: project, language: language} do + params = %{project_id: project.id, language: language.slug, document_format: "json", document_path: "foo"} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.status == 404 + end + + test "export version", %{conn: conn, user: user, project: project, revision: revision, language: language} do + version = %Version{project_id: project.id, user_id: user.id, name: "Current", tag: "master"} |> Repo.insert!() + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", corrected_text: "foo", proposed_text: "foo", document_id: document.id, version_id: version.id} |> Repo.insert!() + + params = %{version: "master", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.resp_body == """ + { + "test": "foo" + } + """ + end + + test "export without version", %{conn: conn, user: user, project: project, revision: revision, language: language} do + version = %Version{project_id: project.id, user_id: user.id, name: "Current", tag: "master"} |> Repo.insert!() + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", corrected_text: "foo", proposed_text: "foo", document_id: document.id, version_id: version.id} |> Repo.insert!() + + params = %{project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.resp_body == """ + { + "ok": "bar" + } + """ + end + + test "export with unknown version", %{conn: conn, project: project, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + + params = %{version: "foo", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.status == 404 + end + + test "export with order", %{conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", corrected_text: "foo", proposed_text: "foo", document_id: document.id} |> Repo.insert!() + + params = %{order_by: "key", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.resp_body == """ + { + "ok": "bar", + "test": "foo" + } + """ + end + + test "export with default order", %{conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "ok", corrected_text: "bar", proposed_text: "bar", document_id: document.id, file_index: 2} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", corrected_text: "foo", proposed_text: "foo", document_id: document.id, file_index: 1} |> Repo.insert!() + + params = %{order_by: "", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> get(export_path(conn, [], params)) + + assert response.resp_body == """ + { + "test": "foo", + "ok": "bar" + } + """ + end +end diff --git a/test/web/controllers/merge_controller_test.exs b/test/web/controllers/merge_controller_test.exs new file mode 100644 index 00000000..fbe37b5e --- /dev/null +++ b/test/web/controllers/merge_controller_test.exs @@ -0,0 +1,131 @@ +defmodule AccentTest.MergeController do + use Accent.ConnCase + + import Ecto.Query, only: [from: 2] + import Mox + setup :verify_on_exit! + + alias Accent.{ + Repo, + Project, + Revision, + Document, + User, + AccessToken, + Collaborator, + Translation, + Operation, + Language + } + + @user %User{email: "test@test.com"} + + def file(filename \\ "simple.json") do + %Plug.Upload{content_type: "application/json", filename: filename, path: "test/support/formatter/json/simple.json"} + end + + setup do + user = Repo.insert!(@user) + access_token = %AccessToken{user_id: user.id, token: "test-token"} |> Repo.insert!() + french_language = %Language{name: "french", slug: Ecto.UUID.generate()} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + %Collaborator{project_id: project.id, user_id: user.id, role: "admin"} |> Repo.insert!() + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + + {:ok, [access_token: access_token, user: user, project: project, revision: revision, language: french_language]} + end + + test "merge default", %{user: user, access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(merge_path(conn, []), body) + + assert response.status == 200 + + merge_on_proposed_operation = from(o in Operation, where: [action: ^"merge_on_proposed"]) |> Repo.one() + merge_operation = from(o in Operation, where: [action: ^"merge"]) |> Repo.one() + + assert merge_on_proposed_operation.batch_operation_id == merge_operation.id + assert merge_operation.user_id == user.id + assert merge_operation.revision_id == revision.id + end + + test "merge passive", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), merge_type: "passive", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(merge_path(conn, []), body) + + assert response.status == 200 + + merge_on_proposed_operation = from(o in Operation, where: [action: ^"merge_on_proposed"]) |> Repo.one() + merge_operation = from(o in Operation, where: [action: ^"merge"]) |> Repo.one() + + assert merge_on_proposed_operation == nil + assert merge_operation == nil + end + + test "merge force", %{user: user, access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: false, corrected_text: "initial", proposed_text: "modified", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), merge_type: "force", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(merge_path(conn, []), body) + + assert response.status == 200 + + merge_on_corrected_force_operation = from(o in Operation, where: [action: ^"merge_on_corrected_force"]) |> Repo.one() + merge_operation = from(o in Operation, where: [action: ^"merge"]) |> Repo.one() + + assert merge_on_corrected_force_operation.batch_operation_id == merge_operation.id + assert merge_on_corrected_force_operation.text == "F" + assert merge_operation.user_id == user.id + assert merge_operation.revision_id == revision.id + end + + test "merge old route", %{user: user, access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post("/merge", body) + + assert response.status == 200 + + merge_on_proposed_operation = from(o in Operation, where: [action: ^"merge_on_proposed"]) |> Repo.one() + merge_operation = from(o in Operation, where: [action: ^"merge"]) |> Repo.one() + + assert merge_on_proposed_operation.batch_operation_id == merge_operation.id + assert merge_operation.user_id == user.id + assert merge_operation.revision_id == revision.id + end +end diff --git a/test/web/controllers/peek_controller_test.exs b/test/web/controllers/peek_controller_test.exs new file mode 100644 index 00000000..6197adac --- /dev/null +++ b/test/web/controllers/peek_controller_test.exs @@ -0,0 +1,175 @@ +defmodule AccentTest.PeekController do + use Accent.ConnCase + + import Mox + setup :verify_on_exit! + + alias Accent.{ + Repo, + Project, + Revision, + Document, + User, + AccessToken, + Collaborator, + Translation, + Language + } + + @user %User{email: "test@test.com"} + + def file(filename \\ "simple.json") do + %Plug.Upload{content_type: "application/json", filename: filename, path: "test/support/formatter/json/simple.json"} + end + + setup do + user = Repo.insert!(@user) + access_token = %AccessToken{user_id: user.id, token: "test-token"} |> Repo.insert!() + french_language = %Language{name: "french", slug: Ecto.UUID.generate()} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + %Collaborator{project_id: project.id, user_id: user.id, role: "admin"} |> Repo.insert!() + revision = %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + + {:ok, [access_token: access_token, user: user, project: project, revision: revision, language: french_language]} + end + + test "merge", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "peek_merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(peek_add_translations_path(conn, :merge), body) + |> json_response(200) + + assert get_in(response, ["data", "stats", revision.id]) == %{"merge_on_proposed" => 1} + + assert get_in(response, ["data", "operations", revision.id]) == [ + %{ + "action" => "merge_on_proposed", + "key" => "test", + "previous-text" => "initial", + "text" => "F" + } + ] + end + + test "merge old route", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "peek_merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(peek_merge_path(conn, :merge), body) + |> json_response(200) + + assert get_in(response, ["data", "stats", revision.id]) == %{"merge_on_proposed" => 1} + + assert get_in(response, ["data", "operations", revision.id]) == [ + %{ + "action" => "merge_on_proposed", + "key" => "test", + "previous-text" => "initial", + "text" => "F" + } + ] + end + + test "merge passive", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), merge_type: "passive", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "peek_merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(peek_merge_path(conn, :merge), body) + |> json_response(200) + + assert get_in(response, ["data", "stats"]) == %{} + assert get_in(response, ["data", "operations"]) == %{} + end + + test "merge force", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: false, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), merge_type: "force", project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "peek_merge"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(peek_merge_path(conn, :merge), body) + |> json_response(200) + + assert get_in(response, ["data", "stats", revision.id]) == %{"merge_on_proposed_force" => 1} + + assert get_in(response, ["data", "operations", revision.id]) == [ + %{ + "action" => "merge_on_proposed_force", + "key" => "test", + "previous-text" => "initial", + "text" => "F" + } + ] + end + + test "sync", %{access_token: access_token, conn: conn, project: project, revision: revision, language: language} do + document = %Document{project_id: project.id, path: "test2", format: "json"} |> Repo.insert!() + %Translation{revision_id: revision.id, key: "test", conflicted: true, corrected_text: "initial", proposed_text: "initial", document_id: document.id} |> Repo.insert!() + + body = %{file: file(), project_id: project.id, language: language.slug, document_format: document.format, document_path: document.path} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn %{event: "peek_sync"} -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(peek_sync_path(conn, :sync), body) + |> json_response(200) + + assert get_in(response, ["data", "stats", revision.id]) == %{"conflict_on_proposed" => 1, "new" => 2} + + assert get_in(response, ["data", "operations", revision.id]) == [ + %{ + "action" => "conflict_on_proposed", + "key" => "test", + "previous-text" => "initial", + "text" => "F" + }, + %{ + "action" => "new", + "key" => "test2", + "previous-text" => nil, + "text" => "D" + }, + %{ + "action" => "new", + "key" => "test3", + "previous-text" => nil, + "text" => "New history please" + } + ] + end +end diff --git a/test/web/controllers/sync_controller_test.exs b/test/web/controllers/sync_controller_test.exs new file mode 100644 index 00000000..f7e66396 --- /dev/null +++ b/test/web/controllers/sync_controller_test.exs @@ -0,0 +1,67 @@ +defmodule AccentTest.SyncController do + use Accent.ConnCase + + import Ecto.Query, only: [from: 2] + import Mox + setup :verify_on_exit! + + alias Accent.{ + Repo, + Project, + Revision, + Document, + User, + AccessToken, + Collaborator, + Operation, + Language + } + + @user %User{email: "test@test.com"} + + def file(filename \\ "simple.json") do + %Plug.Upload{content_type: "application/json", filename: filename, path: "test/support/formatter/json/simple.json"} + end + + setup do + user = Repo.insert!(@user) + access_token = %AccessToken{user_id: user.id, token: "test-token"} |> Repo.insert!() + french_language = %Language{name: "french", slug: Ecto.UUID.generate()} |> Repo.insert!() + project = %Project{name: "My project"} |> Repo.insert!() + + %Collaborator{project_id: project.id, user_id: user.id, role: "admin"} |> Repo.insert!() + %Revision{language_id: french_language.id, project_id: project.id, master: true} |> Repo.insert!() + + {:ok, [access_token: access_token, user: user, project: project, language: french_language]} + end + + test "sync with operations", %{user: user, access_token: access_token, conn: conn, project: project, language: language} do + body = %{file: file(), project_id: project.id, language: language.slug, document_format: "json", document_path: "simple"} + + Accent.Hook.BroadcasterMock + |> expect(:fanout, fn _ -> :ok end) + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(sync_path(conn, []), body) + + assert response.status == 200 + + assert Enum.map(Repo.all(Document), &Map.get(&1, :path)) == ["simple"] + + new_operations = from(o in Operation, where: [action: ^"new"]) |> Repo.all() + sync_operation = from(o in Operation, where: [action: ^"sync"]) |> Repo.one() + + assert length(new_operations) == 3 + assert sync_operation.user_id == user.id + assert sync_operation.project_id == project.id + + response = + conn + |> put_req_header("authorization", "Bearer #{access_token.token}") + |> post(sync_path(conn, []), body) + + assert response.status == 200 + end +end diff --git a/test/web/controllers/webapp_controller_test.exs b/test/web/controllers/webapp_controller_test.exs new file mode 100644 index 00000000..a1111255 --- /dev/null +++ b/test/web/controllers/webapp_controller_test.exs @@ -0,0 +1,23 @@ +defmodule AccentTest.WebappController do + use Accent.ConnCase + + test "index", %{conn: conn} do + response = + conn + |> get(web_app_path(conn, [])) + + assert response.status == 200 + assert response.state == :file + assert get_resp_header(response, "content-type") == ["text/html; charset=utf-8"] + end + + test "catch all", %{conn: conn} do + response = + conn + |> get("/app/foo") + + assert response.status == 200 + assert response.state == :file + assert get_resp_header(response, "content-type") == ["text/html; charset=utf-8"] + end +end diff --git a/test/web/graphiql_test.exs b/test/web/graphiql_test.exs new file mode 100644 index 00000000..6586157a --- /dev/null +++ b/test/web/graphiql_test.exs @@ -0,0 +1,12 @@ +defmodule AccentTest.GraphiqlInterface do + use Accent.ConnCase + + test "graphiql", %{conn: conn} do + response = + conn + |> get("/graphiql", %{query: "query { __schema { queryType { name } } }"}) + + assert response.resp_body == ~S({"data":{"__schema":{"queryType":{"name":"RootQueryType"}}}}) + assert response.status == 200 + end +end diff --git a/test/web/graphql_test.exs b/test/web/graphql_test.exs new file mode 100644 index 00000000..3fc67053 --- /dev/null +++ b/test/web/graphql_test.exs @@ -0,0 +1,12 @@ +defmodule AccentTest.GraphqlInterface do + use Accent.ConnCase + + test "graphql", %{conn: conn} do + response = + conn + |> get("/graphql", %{query: "query { __schema { queryType { name } } }"}) + + assert response.resp_body == ~S({"data":{"__schema":{"queryType":{"name":"RootQueryType"}}}}) + assert response.status == 200 + end +end diff --git a/webapp/.editorconfig b/webapp/.editorconfig new file mode 100644 index 00000000..f72de949 --- /dev/null +++ b/webapp/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.js] +indent_style = space +indent_size = 2 + +[*.hbs] +indent_style = space +indent_size = 2 + +[*.css] +indent_style = space +indent_size = 2 + +[*.html] +indent_style = space +indent_size = 2 + +[*.{diff,md}] +trim_trailing_whitespace = false diff --git a/webapp/.ember-cli b/webapp/.ember-cli new file mode 100644 index 00000000..89201c27 --- /dev/null +++ b/webapp/.ember-cli @@ -0,0 +1,5 @@ +{ + "disableAnalytics": false, + "usePods": true, + "output-path": "../priv/static/webapp" +} diff --git a/webapp/.eslintignore b/webapp/.eslintignore new file mode 100644 index 00000000..412ee908 --- /dev/null +++ b/webapp/.eslintignore @@ -0,0 +1,8 @@ +node_modules/* +**/vendor/*.js +vendor/**/*.js +bower_components/**/*.js +dist/**/*.js +tmp/**/*.js +app/locales/*/translations.js +app/utils/phoenix.js diff --git a/webapp/.eslintrc b/webapp/.eslintrc new file mode 100644 index 00000000..cae59624 --- /dev/null +++ b/webapp/.eslintrc @@ -0,0 +1,76 @@ + +{ + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": 1 + } + }, + "rules": { + "camelcase": [2, {"properties": "always"}], + "complexity": [2, 10], + "consistent-this": [0, "self"], + "dot-notation": 2, + "eqeqeq": [2, "smart"], + "func-style": [2, "expression"], + "linebreak-style": [2, "unix"], + "no-alert": 2, + "no-console": 2, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-empty": 2, + "no-empty-character-class": 2, + "no-eval": 2, + "no-ex-assign": 2, + "no-inner-declarations": [2, "both"], + "no-magic-numbers": [2, {"enforceConst": true, ignore: [-1, 0, 1, 2, 100]}], + "no-nested-ternary": 2, + "no-obj-calls": 2, + "no-sparse-arrays": 2, + "no-throw-literal": 2, + "no-undef": 2, + "no-unreachable": 2, + "no-unused-vars": [2, {"args": "all", "argsIgnorePattern": "^_", "vars": "all", "varsIgnorePattern": "^_"}], + "no-useless-call": 2, + "no-useless-concat": 2, + "no-var": 2, + "no-void": 2, + "object-shorthand": 2, + "operator-linebreak": 0, + "padded-blocks": 0, + "prefer-arrow-callback": 2, + "prefer-const": 2, + "prefer-template": 2, + "quotes": [2, "single", "avoid-escape"], + "radix": 2, + "space-before-function-paren": 0, + "space-in-brackets": 0, + "spaced-comment": 2, + "strict": 0, + + "ember/jquery-ember-run": 2, + "ember/use-brace-expansion": 2, + "ember/no-side-effects": 2, + "ember/order-in-routes": 2, + "ember/order-in-models": 2, + "ember/order-in-controllers": 2, + "ember/no-empty-attrs": 2, + "ember/closure-actions": 2 + }, + "plugins": [ + "ember" + ], + "extends": [ + "prettier" + ], + "env": { + "browser": true, + "es6": true + }, + "globals": { + "JsDiff": true, + "Spinner": true + } +} diff --git a/webapp/.scss-lint.yml b/webapp/.scss-lint.yml new file mode 100644 index 00000000..027882d6 --- /dev/null +++ b/webapp/.scss-lint.yml @@ -0,0 +1,18 @@ +linters: + CapitalizationInSelector: + enabled: false + PropertySortOrder: + enabled: false + LeadingZero: + enabled: false + BorderZero: + enabled: false + SelectorDepth: + enabled: true + max_depth: 4 + DuplicateProperty: + enabled: false + MergeableSelector: + enabled: false + SelectorFormat: + convention: '[a-zA-Z0-9\-]+' diff --git a/webapp/.svgo.yml b/webapp/.svgo.yml new file mode 100644 index 00000000..17aeac34 --- /dev/null +++ b/webapp/.svgo.yml @@ -0,0 +1,37 @@ +# Replace default config +full: true + +plugins: + - removeDoctype + - removeXMLProcInst + - removeComments + - removeMetadata + - removeEditorsNSData + - cleanupAttrs + - convertStyleToAttrs + - cleanupIDs + - removeRasterImages + - removeUselessDefs + - cleanupNumericValues + - cleanupListOfValues + - convertColors + - removeUnknownsAndDefaults + - removeNonInheritableGroupAttrs + - removeUselessStrokeAndFill + - removeViewBox + - cleanupEnableBackground + - removeHiddenElems + - removeEmptyText + - convertShapeToPath + - moveElemsAttrsToGroup + - moveGroupAttrsToElems + - collapseGroups + - convertPathData + - convertTransform + - removeEmptyAttrs + - removeEmptyContainers + - mergePaths + - removeUnusedNS + - transformsWithOnePath + - removeTitle + - removeDesc diff --git a/webapp/.travis.yml b/webapp/.travis.yml new file mode 100644 index 00000000..fbc9ea12 --- /dev/null +++ b/webapp/.travis.yml @@ -0,0 +1,28 @@ +--- +language: node_js +node_js: + - 8.2.1 + +sudo: false + +cache: + directories: + - node_modules + - bower_components + +before_install: + - export PATH=/usr/local/phantomjs-2.0.0/bin:$PATH + - npm config set spin false + - npm install -g npm@^2 + +install: + - npm install + +script: + - npm run lint + - npm run prettier-check + +notifications: + email: + on_success: 'change' + on_failure: 'change' diff --git a/webapp/.watchmanconfig b/webapp/.watchmanconfig new file mode 100644 index 00000000..5e9462c2 --- /dev/null +++ b/webapp/.watchmanconfig @@ -0,0 +1,3 @@ +{ + "ignore_dirs": ["tmp"] +} diff --git a/webapp/app/app.js b/webapp/app/app.js new file mode 100644 index 00000000..b6c3e263 --- /dev/null +++ b/webapp/app/app.js @@ -0,0 +1,18 @@ +import Application from '@ember/application'; +import Ember from 'ember'; +import Resolver from './resolver'; +import loadInitializers from 'ember-load-initializers'; +import config from './config/environment'; + +Ember.MODEL_FACTORY_INJECTIONS = true; + +const {modulePrefix, podModulePrefix} = config; +const App = Application.extend({ + modulePrefix, + podModulePrefix, + Resolver +}); + +loadInitializers(App, modulePrefix); + +export default App; diff --git a/webapp/app/component-helpers/percentage.js b/webapp/app/component-helpers/percentage.js new file mode 100644 index 00000000..ecc523f9 --- /dev/null +++ b/webapp/app/component-helpers/percentage.js @@ -0,0 +1,11 @@ +export default (count, total) => { + const percentage = count / total * 100; + + if (percentage) { + if (percentage % 1 !== 0) return percentage.toFixed(2); + + return percentage; + } + + return 0; +}; diff --git a/webapp/app/computed-macros/parsed-key.js b/webapp/app/computed-macros/parsed-key.js new file mode 100644 index 00000000..13d396c3 --- /dev/null +++ b/webapp/app/computed-macros/parsed-key.js @@ -0,0 +1,17 @@ +import EmberObject, {computed} from '@ember/object'; + +export default property => { + return computed(property, function() { + const key = this.get(property); + + if (!key) return EmberObject.create({value: '', prefix: ''}); + + const splittedKey = key.split('|'); + const isSplitted = !!splittedKey[1]; + + return EmberObject.create({ + value: isSplitted ? splittedKey[1] : key, + prefix: isSplitted ? splittedKey[0] : '' + }); + }); +}; diff --git a/webapp/app/helpers/string-diff.js b/webapp/app/helpers/string-diff.js new file mode 100644 index 00000000..097f7611 --- /dev/null +++ b/webapp/app/helpers/string-diff.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +const {Handlebars: {Utils: {escapeExpression}}} = Ember; + +import {helper} from '@ember/component/helper'; +import {htmlSafe} from '@ember/string'; + +const REMOVED_TAG_TEMPLATE = value => `${value}`; +const ADDED_TAG_TEMPLATE = value => `${value}`; + +const stringDiff = ([text1, text2]) => { + const diff = JsDiff.diffWords(text2 || '', text1 || ''); + + return htmlSafe( + diff + .map(part => { + const value = escapeExpression(part.value); + if (part.removed) return REMOVED_TAG_TEMPLATE(value); + if (part.added) return ADDED_TAG_TEMPLATE(value); + + return value; + }) + .join('') + ); +}; + +export default helper(stringDiff); diff --git a/webapp/app/helpers/time-ago-in-words.js b/webapp/app/helpers/time-ago-in-words.js new file mode 100644 index 00000000..634e7cbd --- /dev/null +++ b/webapp/app/helpers/time-ago-in-words.js @@ -0,0 +1,16 @@ +import {isBlank} from '@ember/utils'; +import {helper} from '@ember/component/helper'; +import distanceInWordsToNow from 'npm:date-fns/distance_in_words_to_now'; + +const OPTIONS = { + addSuffix: true, + includeSeconds: true +}; + +const timeAgoInWords = ([date]) => { + if (isBlank(date)) return ''; + + return distanceInWordsToNow(new Date(date), OPTIONS); +}; + +export default helper(timeAgoInWords); diff --git a/webapp/app/index.html b/webapp/app/index.html new file mode 100644 index 00000000..78de755f --- /dev/null +++ b/webapp/app/index.html @@ -0,0 +1,29 @@ + + + + + + Accent + + + + + {{content-for "head"}} + + + + + {{content-for "head-footer"}} + + + {{content-for "body"}} + + + + + + {{content-for "body-footer"}} + +
+ + diff --git a/webapp/app/instance-initializers/raven-setup.js b/webapp/app/instance-initializers/raven-setup.js new file mode 100644 index 00000000..7f826278 --- /dev/null +++ b/webapp/app/instance-initializers/raven-setup.js @@ -0,0 +1,17 @@ +import Raven from 'npm:raven-js'; +import config from 'accent-webapp/config/environment'; + +export const initialize = application => { + if (config.SENTRY.DSN) { + Raven.config(config.SENTRY.DSN).install(); + + const lookupName = 'service:raven'; + const service = application.lookup ? application.lookup(lookupName) : application.container.lookup(lookupName); + service.enableGlobalErrorCatching(); + } +}; + +export default { + name: 'raven-setup', + initialize +}; diff --git a/webapp/app/locales/en/config.js b/webapp/app/locales/en/config.js new file mode 100644 index 00000000..c5e487c8 --- /dev/null +++ b/webapp/app/locales/en/config.js @@ -0,0 +1,8 @@ +export default { + pluralForm(n) { + if (n === 0) return 'zero'; + if (n === 1) return 'one'; + + return 'other'; + } +}; diff --git a/webapp/app/locales/en/translations.js b/webapp/app/locales/en/translations.js new file mode 100644 index 00000000..52ab853c --- /dev/null +++ b/webapp/app/locales/en/translations.js @@ -0,0 +1,865 @@ +export default { + addon: { + channel: { + handle_in: { + create_collaborator: '{{user}} invited {{collaboratorEmail}} on the project', + create_comment: '{{user}} commented on {{translationKey}}: {{commentText}}', + sync: '{{user}} synced a file: {{documentPath}}' + } + } + }, + components: { + documents_add_button: { + link: 'Add new file' + }, + versions_add_button: { + link: 'Create new version' + }, + revision_selector: { + languages_count: { + one: '1 other language', + other: '{{count}} other languages' + }, + master: 'master' + }, + translation_comments_subscriptions: { + title: 'Notify on new messages' + }, + google_login_form: { + title: 'Google authentication', + subtitle: + 'We use your Google account for authentication only, we will not spam you or access any of your sensitive informations.', + login_button: 'Login with Google' + }, + dummy_login_form: { + title: 'Fake authentication', + warning: 'Looks like you don’t have a valid Google API setup :(', + subtitle: + 'You can use any email to be automatically logged in the platform. Be sure that the API you’re connecting to is in dev mode and not built for production. The fake login setup is disabled in production.', + login_button: 'Login with any email' + }, + hero_header: { + nav: { + why: 'Why?', + features: 'Features', + login_or_signup: 'Login / signup', + projects: 'My projects' + } + }, + activity_item: { + rollbacked: 'Rollbacked', + details: 'Details', + empty_text: 'Empty text' + }, + application_footer: { + accent_bot: 'Accent CLI', + text: 'Made with computers by' + }, + collaborator_create_form: { + create_button: 'Add collaborator', + email_placeholder: 'Add a collaborator by email…' + }, + commit_file: { + cancel_button: 'Cancel', + commit_error: 'An error occured while uploading the file', + document_format: 'Format of the file', + document_path: 'Name of the file', + language: 'Language', + merge_type: 'Mode', + file_source: 'Choosen file', + merge_button: 'Add translations', + pattern_help: 'You need to specify the pattern of the files included in the zip that will be imported, eg: **/*.strings', + peek_button: 'Preview sync', + peek_error: 'An error occured while previewing the file', + peek_help: + 'You can preview the effect of your file(s) on the project. This action won’t do anything to your project, you’re safe to preview whatever you want ;)', + sync_button: 'Sync', + upload_help: 'After choosing a file you will be able to preview the changes on your project' + }, + conflict_item: { + correct_button_text: 'Mark as reviewed', + correct_error_text: 'Failed to correct the conflict. Try again later.', + no_previous_text: 'No previous text', + same_text: 'Same previous text', + uncorrect_button_text: 'Uncorrect', + uncorrect_error_text: 'Failed to uncorrect the conflict. Try again later.' + }, + conflicts_actions: { + reload_button: 'Reload' + }, + conflicts_filters: { + total_entries_count: { + one: '1 string to review', + other: '{{count}} strings to review', + zero: 'No strings to review' + }, + reference_default_option_text: 'No reference langugage', + document_default_option_text: 'All documents', + input_placeholder_text: 'Search for a string' + }, + conflicts_items: { + correct_all_button: 'Mark all strings as reviewed for this language', + fullscreen: 'Fullscreen', + no_translations: 'No strings to review for: {{query}}', + review_completed: 'All reviewed!' + }, + dashboard_master_revision: { + features: { + documents: { + text: 'Manage your files, view a preview of the exported content, and finally, save your file on your machine.', + title: 'Files' + }, + export: { + text: 'Save all strings in a file to include it in your project.', + title: 'Export' + }, + review: { + text: + 'Mark strings as reviewed, correct them while seeing a text diff of the previous content. You can even see the text of another language for the matching string.', + title: 'Review' + }, + sync: { + text: 'Upload your localization files to add, remove and update strings.', + title: 'Sync' + }, + translations: { + text: + 'Search and filter your strings to quickly navigate in your project and see which strings are in review, commented on or recently updated.', + title: 'All strings' + } + }, + keys: 'strings', + last_synced_at_label: 'Last sync:', + master_language_label: 'Master language is:', + never_synced: 'Sync the project for the first time', + reviewed: 'reviewed' + }, + dashboard_navigation: { + collaborators_link_title: 'Collaborators', + edit_link_title: 'Edit', + overview_link_title: 'Overview' + }, + dashboard_revisions: { + manage_languages_link_title: 'Manage languages', + new_language_link_title: 'New language', + new_language_link_text: + 'With another language, you can keep track of translation based on the master language. A conflict in the master will flag the associated string in all slaves languages.', + view_more_activities: 'View more activities →', + title: 'Dashboard', + master: 'Master language', + slaves: 'Translations', + sync: 'Sync', + strings: 'strings', + all_reviewed: 'All reviewed!', + reviewed: 'reviewed', + activities_title: 'Latest activities', + item: { + correct_all_button: 'Mark all strings as reviewed', + uncorrect_all_button: 'Put all strings back in review' + } + }, + date_tag: { + formatted_date_time_format: 'YYYY-MM-DDTHH:mm:ss', + humanized_date_title_format: 'MMMM Do YYYY, HH:mm:ss' + }, + versions_list: { + export: 'Export', + update: 'Edit' + }, + documents_list: { + format: 'Format', + review: 'In review', + total: 'Total', + sync: 'Sync', + merge: 'Add translations', + export: 'Export', + no_strings: 'No strings', + total_strings_count: { + zero: 'No strings', + one: '1 string', + other: '{{count}} strings' + }, + language_strings_count: { + zero: '', + one: '1 string per language', + other: '{{count}} strings per language' + }, + delete_document: 'Remove this file' + }, + error_section: { + logout: 'Logout', + or: 'or', + return: 'return to home' + }, + project_activity: { + stats_label: 'Activities performed:', + overview_label: 'Overview:', + review_label: 'Was reviewed?', + reviewed_yes: 'Yes', + reviewed_no: 'No', + last_synced_text_label: 'Last synced text:', + text_before_action_label: 'Text before the activity:', + new_text_label: 'New text:', + empty_value: 'Empty text', + text_differences_label: 'Differences:', + rollback_operation_label: 'Rollbacked by this activity:', + rollbacked_operation_label: 'Rollbacked this activity:', + batch_operation_label: 'Generated by this activity:', + operations_label: 'Generated these activities:', + rollbacked_label: 'Rollbacked', + file_label: 'File:', + details_label: 'Details:', + explanation_label: 'Why did this happened?', + rollback_confirm: 'Are you sure you want to rollback this activity? This activity cannot be rollbacked.', + rollback: 'Rollback', + stats_label_text: 'Activities performed:', + stats_text: { + merge_on_corrected: 'Translated string', + add_to_version: 'Versioned strings', + version_new: 'Versioned strings', + merge_on_proposed: 'Translated string', + merge_on_proposed_force: 'Translated (force) string', + merge_on_corrected_force: 'Translated (force) string', + conflict_on_proposed: 'New conflict', + conflict_on_corrected: 'New conflict', + conflict_on_slave: 'New conflict reflected in another language', + correct_conflict: 'Strings marked as reviewed', + uncorrect_conflict: 'Strings marked as not reviewed', + update: 'Update string', + new: 'New string', + renew: 'Renew string', + remove: 'Delete string', + update_proposed: 'Update synced text reference' + }, + action_explanation: { + add_to_version: + 'When a user creates a version, the string is copied as a versioned string. The string will remain untouched by the "main" version', + version_new: + 'When a user creates a version, the string is copied as a versioned string. The string will remain untouched by the "main" version', + create_version: + 'When a user freeze the state of all strings to tag it with a version. Used to maintain multiple versions of the same app in parallel.', + conflict_on_corrected: + 'When the uploaded text is different than the last synced text and the last synced text is different than the current text. This happens if the text has been touched by a user in Accent. It is uselful to identify a conflict that is caused by a difference in a sync and the modified version in Accent.', + conflict_on_proposed: + 'When the uploaded text is different than the last uploaded text and the last synced text is equal to the current text. This happens if the text has not been touched by a user in Accent. It is uselful to identify a conflict that is only caused by a sync and not a human intervention.', + conflict_on_slave: + 'When the activity "conflict on proposed" or "conflict on corrected" happens on a string, the matching keys in other languages apply this activity. The goal of this activity is to flag a change of meaning in the master language in the translations. The string will be flagged as "in review"', + correct_all: 'When all the strings are manually marked as reviewed.', + correct_conflict: 'When a string is manually marked as reviewed.', + batch_correct_conflict: 'When multiple strings were marked as reviewed in a short lapse of time.', + document_delete: 'When a file has been deleted.', + merge: + 'When strings are updated with new translations from a file upload. This applies the same logic as the sync activity but without removing strings.', + merge_on_corrected: + 'When the uploaded text is different than the last synced text and the last synced text is different than the current text. This happens if the text has been touched by a user in Accent. It is uselful to identify a conflict that is caused by a difference in the sync upload and the modified version in Accent.', + merge_on_proposed: + 'When the uploaded text is different than the last synced text and the last synced text is equal to the current text. This happens if the text has not been touched by a user in Accent. It is uselful to identify a conflict that is only caused by the sync upload and not a human intervention.', + merge_on_corrected_force: + 'When the uploaded text is different than the last synced text and the last synced text is different than the current text. This happens if the text has been touched by a user in Accent. It is uselful to identify a conflict that is caused by a difference in the sync upload and the modified version in Accent.', + merge_on_proposed_force: + 'When the uploaded text is different than the last synced text and the last synced text is equal to the current text. This happens if the text has not been touched by a user in Accent. It is uselful to identify a conflict that is only caused by the sync upload and not a human intervention.', + remove: 'When the synced file does not contain the key.', + new: 'When the synced file contains a key that is not present in the file.', + renew: 'When the synced file contains a key that was previously removed.', + new_slave: 'When a new language is added to the project.', + rollback: 'When a user manually rollback an activity.', + sync: 'When a document is synced with a file.', + uncorrect_all: 'When all the strings are manually put back in review.', + uncorrect_conflict: 'When the string is manually put back in review.', + batch_update: 'When multiple strings are manually updated by someone.', + update: 'When the string is manually updated by someone.', + update_proposed: + 'When the synced text is the same as the current text but different than the last synced text. The only thing this activity does is make sure that the reference of the last synced text is updated. This does not affect the string’s text.' + }, + action_text: { + add_to_version: 'froze the string in a version', + version_new: 'added the string to a version', + create_version: 'created a new version', + conflict_on_corrected: '’s sync activity created a conflict', + conflict_on_proposed: '’s sync activity created a conflict', + conflict_on_slave: '’s sync activity created a conflict reflected in another language', + correct_conflict: 'marked the string as reviewed', + batch_correct_conflict: 'marked multiple strings as reviewed', + merge_on_corrected: '’s translations additions modified the string', + merge_on_proposed: '’s translations additions modified the string', + merge_on_corrected_force: '’s translations additions (force) modified the string:', + merge_on_proposed_force: '’s translations additions (force) modified the string:', + new: 'added a string', + renew: 're-added a string', + remove: 'removed a string', + rollback: 'rollbacked an operation', + uncorrect_conflict: 'put a string back to review', + update: 'updated a string', + batch_update: 'updated multiple strings', + update_proposed: 'updated an uploaded text reference', + sync: 'synced a file', + new_slave: 'added a new language', + uncorrect_all: 'put all strings back to review', + correct_all: 'marked all strings as reviewed', + document_delete: 'deleted a file', + merge: 'added translations for some strings' + }, + label_text: { + conflict_on_corrected: 'The text has been move to review and is now:', + conflict_on_proposed: 'The text is still in review and is now:', + conflict_on_slave: 'The text has been move to review and is now:' + } + }, + project_settings: { + back_link: { + title: '← Back to settings' + }, + links_list: { + collaborators: 'Collaborators', + badges: 'Badges', + api_token: 'API Token', + service_integrations: 'Service & integrations', + manage_languages: 'Manage languages' + }, + delete_form: { + title: 'Danger zone', + delete_project_title: 'Delete this project', + delete_project_text: 'Once you delete a project, there is no going back.', + delete_project_button: 'Delete this project' + }, + form: { + update_button: 'Update project', + lock_file_operations: { + text_1: 'When the file operations are locked, syncing and adding translations will be disabled.', + remove_lock_button: 'Unlock file operations', + add_lock_button: 'Lock file operations' + } + }, + api_token: { + title: 'API token', + text_1: + 'With this token, you can make authentified calls to Accent’s API. All operations will be flagged as "made by the API client".', + text_2: 'Typically, this is used to sync and add translations the localization files in a deploy script.' + }, + badges: { + title: 'Badges', + text: 'Can be embedded in markdown files or displayed on a web page to show public stats for a given project.', + percentage_reviewed: 'Percentage reviewed:' + }, + collaborators: { + admin_text: 'Can add languages, update the project and add/remove collaborators.', + developer_text: 'Can make file operations: Sync, add translations and preview those operations in the UI.', + owner_text: 'With the same roles as the admin, the owners are people who the project belongs to.', + reviewer_text: 'Can do every others tasks not present in the above roles. Review, update strings, comments, etc.', + title: 'Collaborators' + }, + collaborators_item: { + by: 'by', + cancel_save_role: 'Cancel', + delete_button: 'Remove collaborator', + edit_role: 'Edit role', + joined: 'Join the project', + invited: 'Invited', + save_role: 'Save role', + uninvite_button: 'Remove invitation' + }, + integrations: { + title: 'Service & integrations', + help: 'Services are pre-built integrations that perform certain actions when events occur on Accent.', + save: 'Save', + edit: 'Edit', + delete: 'Delete', + events: { + title: 'Which events would you like to trigger this webhook?', + options: { + sync: 'Sync' + } + } + } + }, + project_activities_filter: { + actions: { + conflict_on_corrected: 'Conflict on corrected', + conflict_on_proposed: 'Conflict on proposed', + conflict_on_slave: 'Conflict on slave', + correct_all: 'Correct all', + correct_conflict: 'Correct conflict', + batch_correct_conflict: 'Correct conflicts', + document_delete: 'File delete', + new: 'New', + renew: 'Renew', + new_comment: 'New comment', + remove: 'Remove', + rollback: 'Rollback', + merge: 'Add translations', + sync: 'Sync', + uncorrect_all: 'Uncorrect all', + uncorrect_conflict: 'Uncorrect conflict', + update: 'Update string', + batch_update: 'Update strings' + }, + actions_default_option_text: 'All activities', + collaborators_default_option_text: 'All collaborators', + only_important_activities_text: 'Only important activities' + }, + project_activities_list: { + title: 'Activities', + empty_activities_text: 'No activities found' + }, + project_activities_list_item: { + stats_label_text: 'Activities performed:', + stats_text: { + add_to_version: 'Versioned strings', + version_new: 'Versioned strings', + merge_on_corrected: 'Translated string', + merge_on_proposed: 'Translated string', + merge_on_proposed_force: 'Translated (force) string', + merge_on_corrected_force: 'Translated (force) string', + conflict_on_proposed: 'New conflict', + conflict_on_corrected: 'New conflict', + conflict_on_slave: 'New conflict reflected in another language', + correct_conflict: 'Strings marked as reviewed', + uncorrect_conflict: 'Strings marked as not reviewed', + update: 'Updated string', + new: 'New string', + renew: 'Renew string', + remove: 'Delete string', + update_proposed: 'Update uploaded text reference:' + }, + action_text: { + add_to_version: 'froze the string in a version:', + version_new: 'added the string to the version:', + create_version: 'created a new version:', + remove: 'removed the string:', + renew: 'added the previously removed string:', + new: 'added the string:', + conflict_on_corrected: 'last sync activity created a conflict:', + conflict_on_proposed: 'last sync activity created a conflict:', + conflict_on_slave: 'last sync activity created a conflict reflected in another language:', + correct_all: 'marked all strings as reviewed', + correct_conflict: 'marked a string as reviewed:', + batch_correct_conflict: 'marked multiple strings as reviewed:', + document_delete: 'deleted a file:', + merge: 'added translations for some strings', + merge_on_corrected: 'last translations additions modified a string:', + merge_on_proposed: 'last translations additions modified a string:', + merge_on_corrected_force: 'last translations additions (force) modified a string:', + merge_on_proposed_force: 'last translations additions (force) modified a string:', + new_comment: 'added a new comment:', + new_slave: 'added a new language', + rollback: 'rollbacked an operation:', + sync: 'synced a file:', + uncorrect_all: 'put all strings back to review', + uncorrect_conflict: 'put a string back to review:', + update: 'updated the string:', + batch_update: 'updated multiple strings:', + update_proposed: 'updated the uploaded text reference:' + }, + label_text: { + conflict_on_corrected: 'The text has been move to review and is now:', + conflict_on_proposed: 'The text is still in review and is now:', + conflict_on_slave: 'The text has been move to review and is now:' + } + }, + project_comments_list: { + no_comments: 'No comments on any strings yet', + title: 'Conversation' + }, + project_create_form: { + title: 'New project', + error: 'Invalid project', + cancel_button: 'Cancel', + language_label: 'Master language:', + language_search_placeholder: 'Search languages…', + name_label: 'Name:', + save_button: 'Create' + }, + version_create_form: { + title: 'New version', + text: + 'Creating a version makes a snapshot of all the active strings (reviewed or not) to be viewable with the certainty that it will remain untouched. This can be useful when maintaining multiple version of the same app.', + error: 'Invalid version', + cancel_button: 'Cancel', + name_label: 'Name:', + tag_label: 'Tag:', + save_button: 'Create' + }, + version_update_form: { + title: 'Update version', + error: 'Invalid version', + cancel_button: 'Cancel', + name_label: 'Name:', + tag_label: 'Tag:', + save_button: 'Update' + }, + project_file_operations: { + sync: 'Sync', + merge: 'Add translations', + export: 'Export', + document_format: 'Format', + preview_title: 'Preview', + preview_text: 'You must choose a file, then click on the Preview button.' + }, + project_navigation: { + activities_link_title: 'Activities', + conversation_link_title: 'Conversation', + translations_link_title: 'All strings', + conflicts_link_title: 'Review', + dashboard_link_title: 'Dashboard', + settings_link_title: 'Settings', + sync_link_title: 'Files', + versions_link_title: 'Versions' + }, + project_manage_languages: { + create_error: 'Language can not be added right now. Try again later.', + conflicts_explain_title: 'On conflicts', + conflicts_explain_text: + 'Every string addition or deletion will be reflected in the language. And when a text changes in the master revision, the string in the language will be mark as in review.', + sync_explain_title: 'On sync', + sync_explain_text: + 'The master language will be the default source when syncing a file. The other languages are never "synced", they just follow the master language.', + add_translations_explain_title: 'On add translations', + add_translations_explain_text: + 'Every languages can have strings "merged" into it by adding translations. Conflict resolution will work the same but will never add or remove strings.', + main_text: 'You can add a new language that will follow the master language.', + title: 'Manage languages' + }, + projects_filters: { + input_placeholder_text: 'Search for a project', + new_project: 'New project' + }, + projects_list: { + last_synced_at_label: 'Last sync:', + never_synced: 'Project was never synced', + no_projects: 'You have no projects yet', + no_projects_query: 'No projects found for: {{query}}', + maybe_create_one: 'Create your first project here →' + }, + related_translations_list: { + comments_label: { + one: '1 comment', + other: '{{count}} comments', + zero: 'No comments' + }, + conflicted_label: 'in review', + last_updated_label: 'Last updated: ', + new_language_link: 'New language', + no_related_translations: 'No translations yet. You need to add another language to your project.' + }, + removed_translation_edit: { + cant_edit: 'This string can’t be edited because it has been removed.' + }, + project_manage_languages_create_form: { + language_search_placeholder: 'Search languages…', + save_button: 'Add language' + }, + project_manage_languages_overview: { + list_languages: 'Here is a list of the project’s languages:', + revision_inserted_at_label: 'Created', + master_badge: 'master', + delete_revision_confirm: + 'Are you sure you want to remove this language from your project? This action cannot be rollbacked.', + delete_revision_button: 'Remove this language', + promote_revision_master_confirm: 'Are you sure you want to use this language as the master language from your project?', + promote_revision_master_button: 'Use as master' + }, + revision_export_options: { + default_format: 'Default format', + orders: { + az: 'Alphabetical order', + original: 'Original order' + }, + save_button: 'Export' + }, + revision_navigation: { + documents_link_title: 'Files', + merge_link_title: 'Add translations', + review_link_title: 'Review', + translations_link_title: 'All strings' + }, + time_ago_in_words_tag: { + formatted_date_time_format: 'YYYY-MM-DDTHH:mm:ss', + humanized_date_title_format: 'dddd, MMMM Do YYYY, H:mm a' + }, + translation_activities_list_item: { + action_text: { + add_to_version: 'froze the string in a version:', + version_new: 'added the string to the version:', + create_version: 'created a new version', + conflict_on_corrected: 'last sync activity created a conflict', + conflict_on_proposed: 'last sync activity created a conflict', + conflict_on_slave: 'last sync activity created a conflict reflected in another language', + correct_conflict: 'marked the string as reviewed', + merge_on_corrected: 'last translations additions modified the string', + merge_on_proposed: 'last translations additions modified the string', + merge_on_corrected_force: 'last translations additions (force) modified the string:', + merge_on_proposed_force: 'last translations additions (force) modified the string:', + new: 'added the string', + renew: 're-added the string', + new_comment: 'added a new comment:', + remove: 'removed the string', + rollback: 'rollbacked an operation', + uncorrect_conflict: 'put the string back to review', + update: 'updated the text', + update_proposed: 'updated the uploaded text reference' + }, + label_text: { + conflict_on_corrected: 'The text has been move to review and is now:', + conflict_on_proposed: 'The text is still in review and is now:', + conflict_on_slave: 'The text has been move to review and is now:' + } + }, + translation_comment_form: { + comment_button: 'Comment', + comment_placeholder: 'Leave a comment…', + submit_error: 'Your comment submission was not successful, try again later.' + }, + translation_comments_list: { + no_comments: 'No comments' + }, + translation_edit: { + source_translation: 'See latest version of the string', + correct_button: 'Update and mark as reviewed', + previous_text: 'Previous text:', + uncorrect_button: 'Put back to review', + uneditable: 'The text is not editable because it as been mark as reviewed', + update_text: 'Update text', + last_updated_label: 'Last updated:', + form: { + true_option: 'true', + false_option: 'false', + integer_type_notice: 'This should only contains integer.', + empty_type_notice: 'The text has been set to an empty string', + null_type_notice: 'The text has been set to null' + } + }, + translation_navigation: { + activities_link_title: 'Activities', + comments_link_title: { + one: 'Conversation (1)', + other: 'Conversation ({{count}})', + zero: 'Conversation' + }, + edit_link_title: 'Edit', + merge_link_title: 'Add translations', + related_translations_link_title: 'Translations' + }, + translation_splash_title: { + conflicted_label: 'in review', + removed_label: 'This string was removed {{removedAt}}' + }, + translations_filter: { + input_placeholder_text: 'Search for a string', + documents_label: 'Filter from document:', + document_default_option_text: 'All documents', + version_default_option_text: 'Latest version', + total_entries_count: { + one: '1 string found', + other: '{{count}} strings found', + zero: 'No strings found' + } + }, + translations_list: { + comments_count: { + one: '1 comment', + other: '{{count}} comments', + zero: 'No comments' + }, + empty_text: 'Empty text', + in_review_label: 'in review', + last_updated_label: 'Last updated: ', + maybe_sync_before: 'Maybe try to', + maybe_sync_link: 'sync some files →', + no_translations: 'Looks like no strings were added for your project.', + no_translations_query: 'No strings found for: {{query}}' + }, + welcome_project: { + welcome: 'Welcome!', + welcome_translations: 'Bienvenue Bienvenido 환영 欢迎 тавтай морилно уу Welkom Tervetuloa', + first_step: 'First step', + after_steps: 'After this, you will be able to', + sync_file: 'Sync a new file', + start_review: 'Start to review and translate', + export: 'Export the corrected file', + help: 'Help?' + } + }, + general: { + application_name: 'Accent', + logout_button: 'Logout', + roles: { + ADMIN: 'Admin', + DEVELOPER: 'Developer', + OWNER: 'Owner', + REVIEWER: 'Reviewer' + }, + integration_services: { + SLACK: 'Slack' + } + }, + pods: { + login: { + title: 'Login or signup' + }, + error: { + not_found: { + status: '404', + title: 'Not found', + text: 'This page doesn’t exist.' + }, + unauthorized: { + status: '401', + title: 'Unauthorized', + text: 'You are not authorized to view this resource.' + }, + internal_error: { + status: '500', + title: 'Internal server error', + text: 'Something bad happened and someone has been notified.' + } + }, + document: { + new_sync: { + document_path: 'New file' + }, + sync: { + flash_messages: { + create_error: 'The document could not be synced with the uploaded file', + create_success: 'The document has been synced with success' + } + }, + merge: { + flash_messages: { + create_error: 'The document could not be uploaded with the uploaded file', + create_success: 'The document has been uploaded with success' + } + }, + index: { + flash_messages: { + delete_error: 'The document could not be removed from the project', + delete_success: 'The document has been removed from the project with success' + } + } + }, + new_project: { + title: 'Create a new project' + }, + project: { + loading_content: 'Loading your project…', + index: { + loading_content: 'Fetching dashboard…', + flash_messages: { + revision_correct_success: 'All strings in the language has been mark as reviewed', + revision_correct_error: 'An error has occured when marking all the strings in that language as reviewed', + revision_uncorrect_success: 'All strings in the language has been mark to be reviewed', + revision_uncorrect_error: 'An error has occured when marking all the strings in that language to be reviewed' + } + }, + activities: { + show: { + loading_activities: 'Fetching activities…', + loading_content: 'Fetching activity’s details…' + }, + flash_messages: { + rollback_success: 'The activity has been rollbacked with success', + rollback_error: 'The activity could not be rollbacked' + } + }, + translations: { + loading_content: 'Searching the strings…' + }, + conflicts: { + loading_content: 'Searching the strings in review…', + flash_messages: { + revision_correct_success: 'All strings in the language has been mark as reviewed', + revision_correct_error: 'An error has occured when marking all the strings in that language as reviewed', + correct_error: 'The string could not be marked as reviewed', + correct_success: 'The string as been marked as reviewed with success' + } + }, + edit: { + title: 'Settings', + loading_content: 'Fetching project’s settings…', + flash_messages: { + collaborator_add_error: 'The collaborator could not be added', + collaborator_add_success: 'The collaborator has been added with success', + collaborator_remove_error: 'The collaborator could not be removed', + collaborator_remove_success: 'The collaborator has been removed with success', + collaborator_update_error: 'The collaborator could not be updated', + collaborator_update_success: 'The collaborator has been updated with success', + integration_add_error: 'The integration could not be added', + integration_add_success: 'The integration has been added with success', + integration_update_error: 'The integration could not be updated', + integration_update_success: 'The integration has been updated with success', + integration_remove_error: 'The integration could not be removed', + integration_remove_success: 'The integration has been removed with success', + update_error: 'The project could not be updated', + update_success: 'The project has been updated with success', + delete_error: 'The project could not be deleted', + delete_success: 'The project has been deleted with success' + }, + collaborators: { + loading_content: 'Fetching project’s collaborators…' + }, + badges: { + loading_content: 'Fetching project’s badges…' + }, + api_token: { + loading_content: 'Fetching project’s API settings…' + }, + service_integrations: { + loading_content: 'Fetching project’s service & integrations…' + } + }, + export: { + loading_content: 'Rendering the language export' + }, + files: { + loading_content: 'Fetching files…', + title: 'Files' + }, + versions: { + loading_content: 'Fetching versions…', + title: 'Versions' + }, + manage_languages: { + loading_content: 'Fetching languages…', + flash_messages: { + add_revision_failure: 'The new language could not be created', + add_revision_success: 'The new language has been created with success', + delete_revision_failure: 'The language could not be deleted', + delete_revision_success: 'The language has been deleted with success', + promote_master_revision_failure: 'The language could not be promoted as master', + promote_master_revision_success: 'The language has been promoted as master with success' + } + } + }, + projects: { + loading_content: 'Fetching your projects…' + }, + versions: { + new: { + flash_messages: { + create_error: 'The version could not be created', + create_success: 'The version has been created with success' + } + }, + edit: { + flash_messages: { + update_error: 'The version could not be updated', + update_success: 'The version has been updated with success' + } + } + }, + translation: { + edit: { + flash_messages: { + correct_error: 'The string could not be marked as reviewed', + correct_success: 'The string as been marked as reviewed with success', + uncorrect_error: 'The string could not be put back to review', + uncorrect_success: 'The string was put back to review with success', + update_error: 'The string could not be updated', + update_success: 'The string has been updated with success' + } + }, + comments: { + loading_content: 'Fetching comments…' + } + } + } +}; diff --git a/webapp/app/mixins/apollo-route.js b/webapp/app/mixins/apollo-route.js new file mode 100644 index 00000000..49d2967c --- /dev/null +++ b/webapp/app/mixins/apollo-route.js @@ -0,0 +1,78 @@ +import {inject as service} from '@ember/service'; +import Mixin from '@ember/object/mixin'; +import EmberObject, {setProperties} from '@ember/object'; + +const PROPS_FN = data => data; + +export default Mixin.create({ + apollo: service(), + + graphql(query, {options, props}) { + props = props || PROPS_FN; + const graphqlObject = () => this.modelFor(this.routeName); + + this._createQuery(query, options); + this._createSubscription(props, graphqlObject); + + return this._currentResult(props); + }, + + deactivate() { + this._super(...arguments); + + this._clearSubscription(); + }, + + _currentResult(props) { + const queryObservable = this.queryObservable; + const result = queryObservable.currentResult(); + const mappedResult = this._mapResult(result, props); + + return EmberObject.create(mappedResult); + }, + + _createQuery(query, options = {}) { + this._clearSubscription(); + + const queryObservable = this.apollo.client.watchQuery({ + query, + ...options + }); + setProperties(this, {queryObservable}); + }, + + _createSubscription(props, graphqlObject) { + const next = result => { + const o = graphqlObject(); + if (!o) return; + + const mappedResult = this._mapResult(result, props); + setProperties(o, mappedResult); + }; + + const querySubscription = this.queryObservable.subscribe({next}); + setProperties(this, {querySubscription}); + }, + + _clearSubscription() { + const subscription = this.querySubscription; + if (subscription) subscription.unsubscribe(); + }, + + _mapResult(result, props) { + if (result.data && Object.keys(result.data).length) { + const data = props(result.data); + + return { + ...data, + loading: result.loading, + refetch: this.queryObservable.refetch, + fetchMore: this.queryObservable.fetchMore, + startPolling: this.queryObservable.startPolling, + stopPolling: this.queryObservable.stopPolling + }; + } else { + return result; + } + } +}); diff --git a/webapp/app/mixins/authenticated-route.js b/webapp/app/mixins/authenticated-route.js new file mode 100644 index 00000000..9ef6caa0 --- /dev/null +++ b/webapp/app/mixins/authenticated-route.js @@ -0,0 +1,12 @@ +import {inject as service} from '@ember/service'; +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + session: service(), + + redirect() { + if (!this.session.credentials) { + this.transitionTo('login'); + } + } +}); diff --git a/webapp/app/mixins/reset-scroll.js b/webapp/app/mixins/reset-scroll.js new file mode 100644 index 00000000..7f9a0723 --- /dev/null +++ b/webapp/app/mixins/reset-scroll.js @@ -0,0 +1,9 @@ +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + activate() { + this._super(); + + window.scrollTo(0, 0); + } +}); diff --git a/webapp/app/pods/application/controller.js b/webapp/app/pods/application/controller.js new file mode 100644 index 00000000..53934544 --- /dev/null +++ b/webapp/app/pods/application/controller.js @@ -0,0 +1,3 @@ +import Controller from '@ember/controller'; + +export default Controller.extend(); diff --git a/webapp/app/pods/application/route.js b/webapp/app/pods/application/route.js new file mode 100644 index 00000000..7e72301d --- /dev/null +++ b/webapp/app/pods/application/route.js @@ -0,0 +1,27 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; +import raven from 'npm:raven-js'; +import config from 'accent-webapp/config/environment'; + +export default Route.extend({ + session: service('session'), + + beforeModel() { + raven.config(config.SENTRY.DSN).install(); + + return new RSVP.Promise(resolve => { + if (!window.gapi || !config.GOOGLE_LOGIN_ENABLED) return resolve(); + + window.gapi.load('auth2', () => { + const auth2 = window.gapi.auth2.init({ + client_id: config.GOOGLE_API.CLIENT_ID, // eslint-disable-line camelcase + cookiepolicy: 'single_host_origin' + }); + + this.set('session.googleAuth', auth2); + resolve(); + }); + }); + } +}); diff --git a/webapp/app/pods/application/template.hbs b/webapp/app/pods/application/template.hbs new file mode 100644 index 00000000..c3e5015c --- /dev/null +++ b/webapp/app/pods/application/template.hbs @@ -0,0 +1,7 @@ +
+
+ {{outlet}} +
+ + {{application-footer}} +
diff --git a/webapp/app/pods/components/acc-badge/component.js b/webapp/app/pods/components/acc-badge/component.js new file mode 100644 index 00000000..76abc37a --- /dev/null +++ b/webapp/app/pods/components/acc-badge/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + classNameBindings: ['link', 'primary'] +}); diff --git a/webapp/app/pods/components/acc-badge/styles.scss b/webapp/app/pods/components/acc-badge/styles.scss new file mode 100644 index 00000000..ca6e668c --- /dev/null +++ b/webapp/app/pods/components/acc-badge/styles.scss @@ -0,0 +1,47 @@ +& { + transition: $transition-speed $transition-easing; + transition-property: background; + display: inline-block; + padding: 1px 6px 1px 5px; + background: lighten($color-grey, 28%); + border-radius: 3px; + color: darken($color-grey, 10%); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + text-decoration: none; +} + +&.primary { + background: $color-primary; + color: $color-white; +} + +&.link { + padding: 0; + + &:focus, + &:hover { + background: lighten($color-grey, 26%); + } +} + +&.link.primary { + padding: 0; + + &:focus, + &:hover { + background: darken($color-primary, 3%); + } +} + +&.link a { + display: inline-block; + padding: 1px 6px 1px 5px; + color: lighten(#000, 50%); + text-decoration: none; +} + +&.link.primary a { + color: $color-white; +} diff --git a/webapp/app/pods/components/acc-flash-message/component.js b/webapp/app/pods/components/acc-flash-message/component.js new file mode 100644 index 00000000..2d463dcf --- /dev/null +++ b/webapp/app/pods/components/acc-flash-message/component.js @@ -0,0 +1,31 @@ +import {computed} from '@ember/object'; +import {readOnly} from '@ember/object/computed'; +import Component from '@ember/component'; + +export default Component.extend({ + classNameBindings: ['isExiting', 'type'], + + isExiting: readOnly('flash.exiting'), + type: readOnly('flash.type'), + + iconPath: computed('type', function() { + switch (this.type) { + case 'success': + return 'assets/check.svg'; + case 'error': + return 'assets/x.svg'; + case 'socket': + return 'assets/activity.svg'; + default: + null; + } + }), + + actions: { + close() { + const flash = this.flash; + + if (flash) flash.destroyMessage(); + } + } +}); diff --git a/webapp/app/pods/components/acc-flash-message/styles.scss b/webapp/app/pods/components/acc-flash-message/styles.scss new file mode 100644 index 00000000..34b27036 --- /dev/null +++ b/webapp/app/pods/components/acc-flash-message/styles.scss @@ -0,0 +1,103 @@ +& { + position: relative; + margin-bottom: 20px; + min-width: 300px; + max-width: 600px; + background: $color-white; + border: 1px solid $color-border; + border-radius: 3px; + box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1); + color: $color-grey; + font-weight: bold; + animation: 0.3s flash-message-in ease; + + &.is-exiting { + animation: 0.3s flash-message-out forwards ease; + } +} + +&.success { + border-color: lighten($color-primary, 35%); + border-bottom: 3px solid $color-primary; + color: $color-primary; + + .icon { + fill: lighten($color-primary, 35%) + } +} + +&.socket { + border-color: lighten($color-socket, 35%); + border-bottom: 3px solid $color-socket; + color: $color-socket; + + .icon { + fill: lighten($color-socket, 35%) + } +} + +&.error { + border-color: lighten($color-error, 35%); + border-bottom: 3px solid $color-error; + color: $color-error; + + .icon { + fill: lighten($color-error, 35%) + } +} + +.content { + display: flex; + align-items: center; + padding: 20px; +} + +.text { + flex: 1 1 auto; + line-height: 1.7; + font-size: 13px; +} + +.icon { + width: 20px; + height: 20px; + margin-right: 20px; +} + +.deleteButton { + position: absolute; + top: 2px; + right: 0; + background: none; + text-align: center; +} + +.deleteButton-icon { + width: 13px; + height: 13px; + fill: $color-grey; +} + +@keyframes flash-message-in { + 0% { + transform: translateY(30px); + opacity: 0; + } + + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes flash-message-out { + 0% { + transform: translateY(0); + opacity: 1; + } + + 100% { + transform: translateY(30px); + opacity: 0; + } +} diff --git a/webapp/app/pods/components/acc-flash-message/template.hbs b/webapp/app/pods/components/acc-flash-message/template.hbs new file mode 100644 index 00000000..ad25f404 --- /dev/null +++ b/webapp/app/pods/components/acc-flash-message/template.hbs @@ -0,0 +1,13 @@ + + +
+ {{#if iconPath}} +
+ {{inline-svg iconPath class='icon'}} +
+ {{/if}} + +

{{flash.message}}

+
diff --git a/webapp/app/pods/components/acc-modal/component.js b/webapp/app/pods/components/acc-modal/component.js new file mode 100644 index 00000000..c95eeb4c --- /dev/null +++ b/webapp/app/pods/components/acc-modal/component.js @@ -0,0 +1,9 @@ +import Component from '@ember/component'; + +export default Component.extend({ + actions: { + close() { + this.onClose(); + } + } +}); diff --git a/webapp/app/pods/components/acc-modal/template.hbs b/webapp/app/pods/components/acc-modal/template.hbs new file mode 100644 index 00000000..4f47f898 --- /dev/null +++ b/webapp/app/pods/components/acc-modal/template.hbs @@ -0,0 +1,9 @@ +{{#ember-wormhole to='modals'}} +
+
+ +
+ {{yield}} +
+
+{{/ember-wormhole}} diff --git a/webapp/app/pods/components/activity-item/component.js b/webapp/app/pods/components/activity-item/component.js new file mode 100644 index 00000000..240049d4 --- /dev/null +++ b/webapp/app/pods/components/activity-item/component.js @@ -0,0 +1,134 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {readOnly, reads, equal} from '@ember/object/computed'; +import Component from '@ember/component'; +import {underscore, dasherize} from '@ember/string'; + +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +/* eslint camelcase:0 */ +const ACTIONS_ICON_PATHS = { + version_new: 'assets/tag.svg', + add_to_version: 'assets/tag.svg', + create_version: 'assets/tag.svg', + sync: 'assets/sync.svg', + merge: 'assets/merge.svg', + rollback: 'assets/revert.svg', + update: 'assets/pencil.svg', + correct_conflict: 'assets/check.svg', + correct_all: 'assets/check.svg', + uncorrect_all: 'assets/revert.svg', + uncorrect_conflict: 'assets/revert.svg', + conflict_on_slave: 'assets/x.svg', + conflict_on_corrected: 'assets/x.svg', + conflict_on_proposed: 'assets/x.svg', + remove: 'assets/x.svg', + new_comment: 'assets/bubble.svg', + new_slave: 'assets/language.svg', + document_delete: 'assets/file.svg' +}; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// showTranslationLink: Boolean +// activity: Object +// componentTranslationPrefix: String +export default Component.extend({ + i18n: service(), + + classNameBindings: ['compact', 'activityItemClassName', 'rollbacked'], + + action: readOnly('activity.action'), + rollbacked: reads('activity.isRollbacked'), + rollbackedOperationHasEmptyText: equal('activity.rollbackedOperation.valueType', 'EMPTY'), + hasEmptyText: equal('activity.valueType', 'EMPTY'), + + translationKey: parsedKeyProperty('activity.translation.key'), + + activityItemClassName: computed('activity.action', function() { + return dasherize(this.activity.action); + }), + + actionText: computed('action', function() { + return this._getActionText(this.action); + }), + + rollbackedOperationActionText: computed('activity.rollbackedOperation.action', function() { + return this._getActionText(this.activity.rollbackedOperation.action); + }), + + showFromOperationTranslationLink: computed('showTranslationLink', 'activity.rollbackedOperation.translation.id', function() { + return ( + this.showTranslationLink && + this.activity.rollbackedOperation && + this.activity.rollbackedOperation.translation && + this.activity.rollbackedOperation.translation.id + ); + }), + + showStats: computed('activity.stats.[]', function() { + return this.activity.stats; + }), + + localizedStats: computed('activity.stats.[]', function() { + return this.activity.stats.map(stat => { + const text = this.i18n.t(`components.${this.componentTranslationPrefix}.stats_text.${underscore(stat.action)}`); + const count = stat.count; + + return {text, count}; + }); + }), + + statsLabel: computed('componentTranslationPrefix', function() { + return this.i18n.t(`components.${this.componentTranslationPrefix}.stats_label_text`); + }), + + showDocumentInfo: computed('action', 'activity.document.path', function() { + const action = this.action; + const actionsWithDocument = ['sync', 'document_delete', 'merge']; + + return actionsWithDocument.includes(action) && readOnly('activity.document.path'); + }), + + showVersionInfo: readOnly('activity.version.id'), + + showRevisionInfo: computed('action', 'activity.revision.language.name', function() { + if (!this.activity.revision) return false; + + const action = this.action; + const actionsWithRevision = [ + 'new', + 'remove', + 'renew', + 'new_slave', + 'merge', + 'uncorrect_all', + 'correct_all', + 'batch_correct_conflict', + 'batch_update', + 'conflict_on_slave' + ]; + + return actionsWithRevision.includes(action) && this.activity.revision.language.name; + }), + + showFromOperationDocumentInfo: computed('activity.rollbackedOperation.{action,document.path}', function() { + const action = this.activity.rollbackedOperation.action; + const actionsWithDocument = ['sync', 'document_delete', 'merge']; + + return actionsWithDocument.includes(action) && readOnly('activity.rollbackedOperation.document.path'); + }), + + isShowingTranslationLink: computed('showTranslationLink', 'activity.{action,translation}', function() { + return this.showTranslationLink && this.activity.translation && this.activity.action !== 'rollback'; + }), + + iconPath: computed('action', function() { + return ACTIONS_ICON_PATHS[this.action] || 'assets/add.svg'; + }), + + _getActionText(action) { + return this.i18n.t(`components.${this.componentTranslationPrefix}.action_text.${action}`); + } +}); diff --git a/webapp/app/pods/components/activity-item/styles.scss b/webapp/app/pods/components/activity-item/styles.scss new file mode 100644 index 00000000..ef39b5e4 --- /dev/null +++ b/webapp/app/pods/components/activity-item/styles.scss @@ -0,0 +1,606 @@ +& { + display: flex; + position: relative; + margin: 25px 0; + border-radius: 3px; + z-index: 10; +} + +&.rollback, +&.merge, +&.new-slave, +&.uncorrect-all, +&.correct-all, +&.sync { + left: 9px; + width: calc(100% + -9px); + padding: 5px 10px; + background: $color-white; + border: 1px solid rgba($color-border, 0.5); + + .item-iconContainer { + top: 3px; + left: -21px; + box-shadow: none; + } + + .item-content { + padding: 5px 0 0; + margin-left: -21px; + background: $color-white; + border-bottom: 0; + } +} + +&.sync { + padding: 6px 10px 5px; + background: lighten($color-primary, 50%); + border-color: rgba($color-primary, 0.1); + + .item-iconContainer { + background: $color-primary; + border-color: rgba($color-primary, 0.3); + box-shadow: none; + } + + .item-iconContainer-icon { + fill: $color-white; + } + + .item-stats { + background: lighten($color-primary, 46%); + color: darken($color-primary, 25%); + } + + .item-date { + color: rgba($color-primary, 0.6); + } + + .item-details-link { + color: $color-primary; + } + + .item-content { + background: lighten($color-primary, 50%); + font-size: 14px; + } + + .item-documentPath { + color: darken($color-primary, 25%); + } + + .item-user, + .item-header-content { + color: darken($color-primary, 25%); + } + + .item-user-icon { + fill: darken($color-primary, 25%); + } + + &.compact { + .item-iconContainer-icon { + fill: $color-primary; + } + } +} + +&.rollback { + background: #fafafa; + border: 1px solid rgba($color-border, 0.3); + + .item-header { + margin-bottom: 4px; + } + + .item-iconContainer { + display: none; + } + + .item-translationLink { + margin-top: 2px; + } + + .item-content { + padding: 5px 0 0; + margin: 0; + background: #fafafa; + border-bottom: 0; + } +} + + +&.uncorrect-all { + border-color: lighten($color-error, 35%); + + .item-iconContainer { + background: $color-error; + border-color: transparent; + } + + .item-iconContainer-icon { + fill: $color-white; + } + + .item-header-content { + color: darken($color-error, 25%); + } +} + +&.correct-all { + border-color: lighten($color-success, 30%); + + .item-iconContainer { + background: $color-success; + border-color: transparent; + } + + .item-iconContainer-icon { + fill: $color-white; + } + + .item-header-content { + color: darken($color-success, 25%); + } +} + +&.document-delete { + border-color: lighten($color-error, 35%); + + .item-iconContainer { + background: $color-error; + border-color: transparent; + } + + .item-iconContainer-icon { + fill: $color-white; + } + + .item-header-content { + color: darken($color-error, 25%); + } + + &.compact { + .item-iconContainer-icon { + fill: $color-error; + } + } +} + +&.correct-conflict { + .item-iconContainer { + border-color: rgba($color-success, 0.4); + } + + .item-iconContainer-icon { + fill: $color-success; + } +} + +&.uncorrect-conflict { + .item-iconContainer { + border-color: rgba($color-error, 0.2); + } + + .item-iconContainer-icon { + fill: $color-error; + } +} + +&.conflict-on-corrected { + .item-iconContainer { + border-color: lighten($color-error, 35%); + } + + .item-iconContainer-icon { + fill: $color-error; + } +} + +&.conflict-on-proposed { + .item-iconContainer { + border-color: lighten($color-warning, 25%); + } + + .item-iconContainer-icon { + fill: $color-warning; + } +} + +&.rollbacked { + .item-stats, + .item-translationText, + .item-actions, + .item-header { + opacity: 0.4; + font-size: 12px; + } + + .item-translationText { + padding: 0; + background: transparent; + } + + .item-iconContainer { + flex: 0 0 16px; + height: 16px; + left: 2px; + top: 1px; + background: #fff; + border-color: rgba($color-grey, 0.4); + } + + .item-iconContainer-icon { + width: 11px; + height: 11px; + fill: rgba($color-grey, 0.5); + } + + &.rollback, + &.merge, + &.new-slave, + &.uncorrect-all, + &.correct-all { + border-color: $color-border; + + .item-iconContainer { + left: -19px; + top: 6px; + } + } + + &.sync { + border-color: rgba($color-primary, 0.1); + + .item-iconContainer { + left: -19px; + top: 6px; + } + } +} + +&.rollback.compact { + .item-iconContainer { + display: block; + } + + .item-content { + margin-left: -21px; + } +} + +&.rollbacked.compact, +&.compact { + width: 100%; + margin: 12px 0 2px; + border-color: transparent; + background: transparent; + box-shadow: none; + + .item-stats { + display: none; + } + + .item-header { + font-size: 11px; + flex-direction: column; + align-items: flex-start; + } + + .item-actions { + margin: 2px 0 0; + text-align: left; + } + + .item-content { + padding-top: 2px; + background: transparent; + border-bottom: 1px solid #eee; + } + + .item-iconContainer { + border-color: #fff; + background: #fff; + } + + .item-rollback-content { + margin: 5px 0; + padding: 5px 10px; + font-size: 11px; + background: #f5f5f5; + color: #888; + } + + .item-content-rollbacked { + margin-bottom: 0; + } + + .item-translationText { + display: none; + } + + .item-translationLink { + font-size: 11px; + margin-left: 0; + margin-top: 0; + } + + .item-revisionLink { + font-size: 11px; + } + + .item-documentPath { + font-size: 11px; + } + + .item-user.item-user--pictureUrl { + padding-left: 0; + } + + .item-user-picture { + display: none; + } +} + +.item-wrapper { + display: flex; + width: 100%; +} + +.item-version-tag { + display: inline-flex; + align-items: baseline; + font-size: 12px; + font-family: $font-monospace; + color: #aaa; +} + +.item-version-icon { + position: relative; + top: 4px; + margin-right: 1px; + width: 16px; + height: 16px; + fill: #aaa; +} + +.item-iconContainer { + display: flex; + position: relative; + top: 0; + flex: 0 0 20px; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 10px; + border-radius: 50%; + border: 1px solid $color-grey; + box-shadow: 0 0 0 5px $color-white; + background: $color-white; +} + +.item-iconContainer-icon { + width: 13px; + height: 13px; + flex: 0 0 13px; + fill: $color-grey; +} + +.item-content { + flex: 1 1 100%; + font-size: 13px; +} + +.item-header { + display: flex; + justify-content: space-between; + margin: 0 0 10px; +} + +.item-header-content { + flex: 1 1 auto; + width: 100%; + font-size: 12px; +} + +.item-user { + position: relative; + display: inline-flex; + align-items: center; + font-weight: bold; + + &.item-user--pictureUrl { + padding-left: 21px; + } + + &.item-user--bot { + position: relative; + margin-left: 17px; + + .item-user-icon { + position: absolute; + left: -19px; + top: 2px; + } + } +} + +.item-user-picture { + position: absolute; + left: 0; + top: 1px; + width: 15px; + height: 15px; + margin-right: 3px; + border-radius: 3px; +} + +.item-user-icon { + margin-right: 3px; + width: 17px; + height: 17px; +} + +.item-translationFromOperationText, +.item-stats, +.item-translationText { + margin: 5px 0 10px; + padding: 8px; + background: #fafafa; + font-size: 12px; + font-style: italic; +} + +.item-translationText-text { + white-space: pre-wrap; +} + +.item-translationFromOperationText { + background: transparent; + padding: 5px 0; + margin: 10px 0 0; +} + +.item-stats-label, +.item-translationText-label { + display: block; + margin-bottom: 4px; + font-size: 11px; + font-weight: bold; + font-style: normal; +} + +.item-translationText-emptyText { + color: #ccc; +} + +.item-documentPath { + display: inline-block; + color: $color-black; + font-weight: bold; + font-size: 12px; + text-decoration: none; + + &:focus, + &:hover { + text-decoration: underline; + } +} + +.item-translationLink { + @extend %translationKeyBase; + display: block; + margin-top: 5px; + text-decoration: none; + font-size: 12px; + font-weight: bold; + transition: $transition-speed $transition-easing; + transition-property: color; + + &:hover, + &:focus { + color: $color-primary; + } + + &.item-translationLink--removed { + color: #666; + } +} + +.item-translationLink-prefix { + display: block; + font-weight: normal; + font-size: 11px; + color: #959595; +} + +.item-revisionLink { + display: inline-block; + text-decoration: none; + color: $color-primary; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +.item-actions { + flex: 1 1 auto; + width: 360px; + margin: 0; + text-align: right; +} + +.item-date { + color: $color-grey; + font-size: 11px; +} + +.item-stats { + font-size: 14px; + list-style: none; +} + +.item-rollback { + transition: $transition-speed $transition-easing; + transition-property: opacity, color; + opacity: 0; + background: none; + padding: 0; + margin: 0 0 0 5px; + color: $color-grey; + font-size: 11px; + + &:focus, + &:hover { + color: darken($color-grey, 25%); + text-decoration: underline; + } +} + +.item-rollback-content { + width: 100%; + padding: 10px; + margin: 10px 0; + background: $color-white; + font-size: 12px; + font-style: italic; + color: #5d6863; + + .item-translationLink { + font-size: 11px; + } + + .item-details-link { + font-weight: bold; + } +} + +.item-rollback-user { + font-weight: bold; +} + +.item-details-link { + padding-left: 6px; + margin-left: 5px; + border-left: 1px solid #ddd; + text-decoration: none; + color: $color-grey; + font-size: 11px; + font-weight: 500; + + &:focus, + &:hover { + text-decoration: underline; + color: $color-primary; + } +} + +.item-content-rollbacked { + margin-bottom: 6px; + color: $color-error; + font-size: 12px; + font-weight: 500; +} diff --git a/webapp/app/pods/components/activity-item/template.hbs b/webapp/app/pods/components/activity-item/template.hbs new file mode 100644 index 00000000..abe77302 --- /dev/null +++ b/webapp/app/pods/components/activity-item/template.hbs @@ -0,0 +1,197 @@ +
  • + + {{inline-svg iconPath class='item-iconContainer-icon'}} + + +
    + {{#if activity.isRollbacked}} +
    + {{t 'components.activity_item.rollbacked'}} + + {{time-ago-in-words-tag + date=activity.updatedAt + class='item-rollbacked-date' + }} +
    + {{/if}} + +
    +
    + {{#if activity.user.isBot}} + + {{inline-svg 'assets/bot.svg' class='item-user-icon'}} + {{activity.user.fullname}} + + {{else}} + + {{#if activity.user.pictureUrl}} + + {{/if}} + + {{activity.user.fullname}} + + {{/if}} + + {{actionText}} + + {{#if showDocumentInfo}} + {{#link-to + 'logged-in.project.files.export' + project.id + activity.document.id + class='item-documentPath' + }} + {{activity.document.path}} + {{/link-to}} + {{/if}} + + {{#if showVersionInfo}} + + {{inline-svg 'assets/tag.svg' class='item-version-icon'}} + {{activity.version.tag}} + + {{/if}} + + {{#if showRevisionInfo}} + {{#link-to + 'logged-in.project.revision.translations' + project.id + activity.revision.id + class='item-revisionLink' + }} + {{activity.revision.language.name}} + {{/link-to}} + {{/if}} + + {{#if isShowingTranslationLink}} + {{#if activity.translation.isRemoved}} + {{#link-to + 'logged-in.project.translation' + project.id + activity.translation.id + class='item-translationLink + item-translationLink--removed' + }} + {{translationKey.prefix}} + {{translationKey.value}} + {{/link-to}} + {{else}} + {{#link-to + 'logged-in.project.translation' + project.id + activity.translation.id + class='item-translationLink' + }} + {{translationKey.prefix}} + {{translationKey.value}} + {{/link-to}} + {{/if}} + {{/if}} +
    + +
    + {{time-ago-in-words-tag + date=activity.insertedAt + class='item-date' + }} + + {{#link-to + 'logged-in.project.activity' + project.id + activity.id + class='item-details-link' + }} + {{t 'components.activity_item.details'}} + {{/link-to}} +
    +
    + + {{#if activity.rollbackedOperation}} +
    +
    + {{activity.rollbackedOperation.user.fullname}} + {{rollbackedOperationActionText}} + + {{#if showFromOperationTranslationLink}} + {{#if activity.rollbackedOperation.translation.isRemoved}} + {{#link-to + 'logged-in.project.translation' + project.id + activity.rollbackedOperation.translation.id + class='item-translationLink--removed' + }} + {{activity.rollbackedOperation.translation.key}} + {{/link-to}} + {{else}} + {{#link-to + 'logged-in.project.translation' + project.id + activity.rollbackedOperation.translation.id + class='item-translationLink' + }} + {{activity.rollbackedOperation.translation.key}} + {{/link-to}} + {{/if}} + {{/if}} + + {{#if activity.fromOperation.text}} +
    + {{activity.fromOperation.text}} +
    + {{else if fromOperationHasEmptyText}} +
    + {{t 'components.activity_item.empty_text'}} +
    + {{/if}} + + {{#if showFromOperationDocumentInfo}} + {{#link-to + 'logged-in.project.files.export' + project.id + activity.rollbackedOperation.document.id + class='item-documentPath' + }} + {{activity.rollbackedOperation.document.path}} + {{/link-to}} + {{/if}} +
    + + {{#unless compact}} + {{time-ago-in-words-tag + date=activity.rollbackedOperation.insertedAt + class='item-date' + }} + + {{#link-to + 'logged-in.project.activity' + project.id + activity.rollbackedOperation.id + class='item-details-link' + }} + {{t 'components.activity_item.details'}} + {{/link-to}} + {{/unless}} +
    + {{/if}} + + {{#if activity.text}} +
    +
    {{activity.text}}
    +
    + {{else if hasEmptyText}} +
    + {{t 'components.activity_item.empty_text'}} +
    + {{/if}} + + {{#if showStats}} +
      + {{statsLabel}} + + {{#each localizedStats as |stat|}} +
    • {{stat.text}}: {{stat.count}}
    • + {{/each}} +
    + {{/if}} +
    +
  • diff --git a/webapp/app/pods/components/application-footer/component.js b/webapp/app/pods/components/application-footer/component.js new file mode 100644 index 00000000..cbfd5011 --- /dev/null +++ b/webapp/app/pods/components/application-footer/component.js @@ -0,0 +1,3 @@ +import Component from '@ember/component'; + +export default Component.extend(); diff --git a/webapp/app/pods/components/application-footer/styles.scss b/webapp/app/pods/components/application-footer/styles.scss new file mode 100644 index 00000000..62f333d8 --- /dev/null +++ b/webapp/app/pods/components/application-footer/styles.scss @@ -0,0 +1,37 @@ +& { + padding: 10px; + margin-top: 40px; + background: #fafafa; + border-top: 1px solid #eee; + color: $color-grey; + font-size: 12px; + text-align: right; +} + +.inner { + @extend %centeredWrapper; + display: flex; + justify-content: space-between; +} + +.link { + display: inline-block; + color: $color-grey; + text-decoration: none; + margin-right: 7px; + + &:focus, + &:hover { + color: $color-primary; + } +} + +.external-link { + color: $color-primary; + text-decoration: none; + + &:focus, + &:hover { + text-decoration: underline; + } +} diff --git a/webapp/app/pods/components/application-footer/template.hbs b/webapp/app/pods/components/application-footer/template.hbs new file mode 100644 index 00000000..9b3fbfc1 --- /dev/null +++ b/webapp/app/pods/components/application-footer/template.hbs @@ -0,0 +1,12 @@ + diff --git a/webapp/app/pods/components/async-button/component.js b/webapp/app/pods/components/async-button/component.js new file mode 100644 index 00000000..9c780078 --- /dev/null +++ b/webapp/app/pods/components/async-button/component.js @@ -0,0 +1,18 @@ +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +export default Component.extend({ + classNameBindings: [':button', 'loading:button--loading'], + tagName: 'button', + attributeBindings: ['disabled', 'type'], + + disabled: reads('loading'), + loading: false, + + click() { + if (this.disabled) return; + const click = this.onClick; + + if (typeof click === 'function') click(); + } +}); diff --git a/webapp/app/pods/components/async-button/styles.scss b/webapp/app/pods/components/async-button/styles.scss new file mode 100644 index 00000000..1c357e23 --- /dev/null +++ b/webapp/app/pods/components/async-button/styles.scss @@ -0,0 +1,48 @@ +& { + padding: 0; + + &.button--loading { + cursor: default; + + .label { + transform: translate3d(-100%, 0, 0); + } + + .loading { + left: calc(50% - 10px); + } + } + + &.button--filled { + .loading { + fill: $color-white; + } + } +} + +.content { + display: flex; + align-items: center; + position: relative; + overflow: hidden; +} + +.label { + transition: $transition-speed $transition-easing; + transition-property: transform; + transform: translate3d(0, 0, 0); + display: flex; + align-items: center; + padding: 5px 12px; +} + +.loading { + transition: $transition-speed $transition-easing; + transition-property: left; + will-change: left; + width: 15px; + position: absolute; + top: -3px; + left: 100%; + fill: $color-black; +} diff --git a/webapp/app/pods/components/async-button/template.hbs b/webapp/app/pods/components/async-button/template.hbs new file mode 100644 index 00000000..8a765f58 --- /dev/null +++ b/webapp/app/pods/components/async-button/template.hbs @@ -0,0 +1,4 @@ +
    + {{yield}} + {{inline-svg '/assets/loading.svg' class='loading'}} +
    diff --git a/webapp/app/pods/components/commit-file/component.js b/webapp/app/pods/components/commit-file/component.js new file mode 100644 index 00000000..0b98a140 --- /dev/null +++ b/webapp/app/pods/components/commit-file/component.js @@ -0,0 +1,185 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {equal} from '@ember/object/computed'; +import Component from '@ember/component'; + +const DEFAULT_PROPERTIES = { + isFileReading: false, + isFileRead: false, + isPeeking: false, + isPeekingDone: false, + isPeekingError: false, + isCommiting: false, + isCommitingDone: false, + isCommitingError: false, + + file: null, + fileSource: null, + documentFormat: 'json' +}; + +// Attributes +// permissions: Ember Object containing +// revisions: Array of +// commitButtonText: String +// onFileCancel: Function +// onPeek: Function +// onCommit: Function +export default Component.extend({ + i18n: service('i18n'), + globalState: service('global-state'), + + init(...args) { + this._super(...args); + + this._initProperties(); + }, + + mergeTypes: ['smart', 'passive', 'force'], + mergeType: 'smart', + + revisionValue: computed('revision', 'revisions.[]', function() { + return this.mappedRevisions.find(({value}) => value === this.revision) || this.mappedRevisions[0]; + }), + + mappedRevisions: computed('revisions.[]', function() { + return this.revisions.map(({id, language}) => ({ + label: language.name, + value: id + })); + }), + + revision: computed('revisions.[]', function() { + return this.revisions.find(revision => revision.isMaster); + }), + + isMerge: equal('commitAction', 'merge'), + + documentFormatValue: computed('documentFormat', 'documentFormatOptions', function() { + return this.documentFormatOptions.find(({value}) => value === this.documentFormat); + }), + + documentFormatOptions: computed('globalState.documentFormats', function() { + if (!this.globalState.documentFormats) return []; + + return this.globalState.documentFormats.map(({slug, name}) => ({ + value: slug, + label: name + })); + }), + + actions: { + onSelectMergeType(mergeType) { + this.set('mergeType', mergeType); + }, + + onSelectRevision(revision) { + this.set('revision', this.revisions.find(({id}) => id === revision.value)); + this.set('revisionValue', revision); + }, + + commit() { + this._onCommiting(); + + this.onCommit(this.getProperties('fileSource', 'documentFormat', 'revision', 'mergeType')) + .then(this._onCommitingDone.bind(this)) + .catch(this._onCommitingError.bind(this)); + }, + + peek() { + this._onPeeking(); + + this.onPeek(this.getProperties('fileSource', 'documentFormat', 'revision', 'mergeType')) + .then(this._onPeekingDone.bind(this)) + .catch(this._onPeekingError.bind(this)); + }, + + fileChange(files) { + const fileSource = files[0]; + const documentFormat = this._formatFromExtension(fileSource.name.split('.').pop()); + const isFileReading = true; + const isFileRead = false; + const reader = new FileReader(); + + this.setProperties({ + fileSource, + isFileReading, + isFileRead, + documentFormat + }); + + reader.onload = this._fileRead.bind(this); + reader.readAsText(files[0]); + }, + + fileCancel() { + this.onFileCancel(); + + this._initProperties(); + } + }, + + _formatFromExtension(fileExtension) { + if (!this.globalState.documentFormats) return null; + + const documentFormatItem = this.globalState.documentFormats.find(({extension}) => extension === fileExtension); + + return documentFormatItem ? documentFormatItem.slug : this.globalState.documentFormats[0].slug; + }, + + /** + * Called after a file is read. + * + * @private + * @method + * @param {ProgressEvent} result Native progress event containing the file raw content and infos + */ + _fileRead(file) { + const isFileReading = false; + const isFileRead = true; + + this.setProperties({ + isFileReading, + isFileRead, + file + }); + }, + + _onCommiting() { + this.setProperties({ + isCommiting: true, + isCommitingDone: false, + isCommitingError: false, + isPeekingError: false + }); + }, + + _onCommitingDone() { + this.setProperties({isCommiting: false, isCommitingDone: true}); + }, + + _onCommitingError() { + this.setProperties({isCommiting: false, isCommitingError: true}); + }, + + _onPeeking() { + this.setProperties({ + isPeeking: true, + isPeekingDone: false, + isPeekingError: false, + isCommitingError: false + }); + }, + + _onPeekingDone() { + this.setProperties({isPeeking: false, isPeekingDone: true}); + }, + + _onPeekingError() { + this.setProperties({isPeeking: false, isPeekingError: true}); + }, + + _initProperties() { + this.setProperties(DEFAULT_PROPERTIES); + } +}); diff --git a/webapp/app/pods/components/commit-file/styles.scss b/webapp/app/pods/components/commit-file/styles.scss new file mode 100644 index 00000000..025805cc --- /dev/null +++ b/webapp/app/pods/components/commit-file/styles.scss @@ -0,0 +1,82 @@ +.separator { + display: block; + margin: 10px 0; + background: #eee; + height: 1px; + width: 100%; +} + +.textHelper { + margin-bottom: 3px; + width: 80%; + color: $color-grey; + font-size: 12px; +} + +.textInput { + margin-bottom: 8px; + padding: 5px; + outline: 0; + border: 1px solid lighten($color-grey, 20%); + box-shadow: inset 0 2px 6px rgba($color-black, 0.05); + background: $color-white; + font-family: $font-monospace; + font-size: 12px; +} + +.options { + display: flex; + border-bottom: 1px solid #eee; + padding: 0; + margin: 0; + font-size: 13px; + + .option { + padding-top: 0; + } +} + +.option { + flex: 1 1 auto; + width: 100%; + border-bottom: 1px solid #eee; + padding: 9px 0; + margin: 0; + + &.option--borderless { + border-bottom: 0; + + &:first-of-type { + margin-right: 10px; + } + } +} + +.actions { + margin-top: 15px; +} + +.peekButton, +.fileInput { + margin-top: 7px; +} + +.errorMessage { + margin: 10px 0; + color: $color-error; + font-size: 13px; + font-weight: bold; +} + +.fileSourceName { + font-size: 14px; + color: #444; +} + +.ember-power-select-trigger { + padding-left: 10px; + border: 1px solid #eee; + background: #fafafa; + box-shadow: 0 1px 5px rgba(#000, 0.06); + color: $color-black; +} diff --git a/webapp/app/pods/components/commit-file/template.hbs b/webapp/app/pods/components/commit-file/template.hbs new file mode 100644 index 00000000..8746f32c --- /dev/null +++ b/webapp/app/pods/components/commit-file/template.hbs @@ -0,0 +1,105 @@ +
    + {{#if isPeekingError}} +
    {{t 'components.commit_file.peek_error'}}
    + {{/if}} + + {{#if isCommitingError}} +
    {{t 'components.commit_file.commit_error'}}
    + {{/if}} + + {{#if isMerge}} +
    +
    +

    {{t 'components.commit_file.language'}}:

    + {{#power-select + searchEnabled=false + selected=revisionValue + options=mappedRevisions + onchange=(action 'onSelectRevision') as |option| + }} + {{option.label}} + {{/power-select}} +
    + +
    +

    {{t 'components.commit_file.merge_type'}}:

    + {{#power-select + searchEnabled=false + selected=mergeType + options=mergeTypes + onchange=(action 'onSelectMergeType') as |option| + }} + {{option}} + {{/power-select}} +
    +
    + {{/if}} + + {{#if file}} +
    +
    +

    {{t 'components.commit_file.file_source'}}

    + {{fileSource.name}} +
    + +
    +

    {{t 'components.commit_file.document_format'}}

    + {{#power-select + searchEnabled=false + selected=documentFormatValue + options=documentFormatOptions + onchange=(action (mut documentFormat) value='value') as |option| + }} + {{option.label}} + {{/power-select}} +
    + + {{#if (get permissions peekAction)}} +
    +

    {{t 'components.commit_file.peek_help'}}

    + + {{#async-button + onClick=(action 'peek') + loading=isPeeking + class='button + button--filled button--blue peekButton' + }} + {{t 'components.commit_file.peek_button'}} + {{/async-button}} +
    + {{/if}} + + {{#if (get permissions commitAction)}} +
    + {{#async-button + onClick=(action 'fileCancel') + class='button button--filled button--white' + }} + {{t 'components.commit_file.cancel_button'}} + {{/async-button}} + + {{#if canCommit}} + {{#async-button + onClick=(action 'commit') + loading=isCommiting class='button button--filled' + }} + {{commitButtonText}} + {{/async-button}} + {{else}} + {{#async-button class='button button--filled button--disabled'}} + {{commitButtonText}} + {{/async-button}} + {{/if}} +
    + {{/if}} +
    + {{else}} +
    +

    {{t 'components.commit_file.upload_help'}}

    + {{file-input + onChange=(action 'fileChange') + class='fileInput' + }} +
    + {{/if}} +
    diff --git a/webapp/app/pods/components/conflict-item/component.js b/webapp/app/pods/components/conflict-item/component.js new file mode 100644 index 00000000..a2c607f1 --- /dev/null +++ b/webapp/app/pods/components/conflict-item/component.js @@ -0,0 +1,62 @@ +import {computed} from '@ember/object'; +import {empty, reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// conflict: Object +// revision: Object (optional) +// onCorrect: Function +export default Component.extend({ + classNameBindings: ['active', 'resolved', 'error:errored', 'fullscreen'], + + emptyPreviousText: empty('conflict.conflictedText'), + textInput: reads('conflict.correctedText'), + samePreviousText: computed('conflict.{conflictedText,correctedText}', function() { + return this.conflict.conflictedText === this.conflict.correctedText; + }), + + loading: false, + error: false, + resolved: false, + active: false, + + conflictKey: parsedKeyProperty('conflict.key'), + + actions: { + correct() { + this._onLoading(); + + this.onCorrect(this.conflict, this.textInput) + .then(this._onCorrectSuccess.bind(this)) + .catch(this._onError.bind(this)); + }, + + inputBlur() { + this.set('active', false); + }, + + inputFocus() { + this.set('active', true); + } + }, + + _onLoading() { + this.setProperties({error: false, loading: true}); + }, + + _onError() { + this.setProperties({error: true, loading: false}); + }, + + _onCorrectSuccess() { + this.setProperties({resolved: true, loading: false}); + }, + + _onUncorrectSuccess() { + this.setProperties({resolved: false, loading: false}); + } +}); diff --git a/webapp/app/pods/components/conflict-item/styles.scss b/webapp/app/pods/components/conflict-item/styles.scss new file mode 100644 index 00000000..d5f6ac35 --- /dev/null +++ b/webapp/app/pods/components/conflict-item/styles.scss @@ -0,0 +1,213 @@ +& { + transition: $transition-speed $transition-easing; + transition-property: background, border-color; + padding: 10px; + margin-bottom: 15px; + border-left: 2px solid transparent; + + &:nth-child(even) { + background: $color-light-grey; + } + + &:focus, + &:hover { + border-color: lighten($color-primary, 40%); + background: $color-light-grey; + } + + &.active { + border-color: lighten($color-primary, 10%); + background: $color-light-grey; + } +} + +&.fullscreen { + .item-details { + display: flex; + align-items: stretch; + } + + .item-details__column { + display: flex; + flex: 1 1 50%; + flex-direction: column; + align-items: flex-start; + } + + .item-details__column:first-of-type { + margin-right: 15px; + } +} + +&[class*='resolved'] { + padding: 5px 10px; + margin: 5px 0 15px; + background: lighten($color-primary, 48%); + border: 1px solid lighten($color-primary, 43%); + border-left: 2px solid $color-primary; + box-shadow: 0 1px 5px lighten($color-primary, 47%); + + .item-key-prefix { + color: rgba($color-primary, 0.7); + } + + .item-key { + font-size: 12px; + color: $color-primary; + } +} + +&[class*='errored'] { + .textInput { + border-color: lighten($color-error, 30%); + } +} + +.error { + font-size: 12px; + font-weight: bold; + color: $color-error; +} + +.item-key-prefix { + display: block; + font-size: 11px; + color: #959595; + font-weight: 300; +} + +.item-key { + display: block; + @extend %translationKeyBase; + transition: $transition-speed $transition-easing; + transition-property: color; + margin-right: 15px; + line-height: 1.5; + font-size: 12px; + font-weight: bold; +} + +.key { + text-decoration: none; + + &:focus, + &:hover { + .item-key { + color: $color-primary; + } + } +} + +.textResolved { + display: flex; + align-items: flex-start; +} + +.textResolved-content { + flex-grow: 1; +} + +.textResolved-text { + margin-bottom: 5px; + font-size: 13px; +} + +.uncorrectButton, +.correctButton { + flex-shrink: 0; +} + +.textDiff, +.textConflicted, +.conflictedText-textReference { + padding: 8px 0; + font-size: 13px; + color: darken($color-grey, 15%); +} + +.textDiff { + border-top: 1px dashed lighten($color-grey, 20%); + white-space: pre-wrap; + + .added { + padding: 0 3px; + background: lighten($color-success, 42%); + color: $color-success; + } + + .removed { + padding: 0 3px; + background: lighten($color-error, 42%); + color: $color-error; + } +} + +.textConflicted { + font-size: 11px; + color: lighten($color-grey, 5%); +} + +.textConflicted-empty { + font-size: 11px; + font-style: italic; + color: lighten($color-grey, 10%); +} + +.textConflicted-content { + white-space: pre-wrap; +} + +.conflictedText-textReference-language-icon { + position: relative; + top: -1px; + width: 15px; + height: 15px; + margin-right: 6px; + opacity: 0.2; +} + +.conflictedText-textReference-link { + margin-right: 4px; + color: rgba($color-black, 0.7); + text-decoration: none; + font-weight: normal; + font-size: 12px; + + &:focus, + &:hover { + color: $color-primary; + } +} + +.conflictedText-textReference { + display: flex; + flex-direction: column; + border-top: 1px dashed #eee; +} + +.conflictedText-textReference-language { + display: flex; + align-items: center; + font-weight: bold; +} + +.conflictedText-textReference-updatedAt { + font-size: 11px; + font-style: italic; + font-weight: normal; + color: lighten($color-grey, 10%); +} + +.conflictedText-textReference-text-content { + display: block; + white-space: pre-wrap; + margin: 3px 0; + font-size: 12px; +} + +.textInput { + flex-grow: 1; + flex-shrink: 0; + width: 100%; + margin: 0 0 5px; +} diff --git a/webapp/app/pods/components/conflict-item/template.hbs b/webapp/app/pods/components/conflict-item/template.hbs new file mode 100644 index 00000000..02bc75c3 --- /dev/null +++ b/webapp/app/pods/components/conflict-item/template.hbs @@ -0,0 +1,96 @@ +
  • + {{#link-to + 'logged-in.project.translation' + project.id + conflict.id + class='key' + }} + + {{conflictKey.prefix}} + {{conflictKey.value}} + + {{/link-to}} + + {{#if resolved}} +
    +
    + {{#if error}} +
    + {{t 'components.conflict_item.uncorrect_error_text'}} +
    + {{/if}} +
    +
    + {{else}} +
    +
    +
    + {{#if emptyPreviousText}} + {{t 'components.conflict_item.no_previous_text'}} + {{else if samePreviousText}} + {{t 'components.conflict_item.same_text'}} + {{else}} + {{conflict.conflictedText}} + {{/if}} +
    + + {{#if emptyPreviousText}} +
    {{conflict.correctedText}}
    + {{else if samePreviousText}} +
    {{conflict.correctedText}}
    + {{else}} +
    {{string-diff conflict.correctedText conflict.conflictedText}}
    + {{/if}} + + {{#if conflict.relatedTranslation.id}} +
    + + {{inline-svg 'assets/language.svg' class='conflictedText-textReference-language-icon'}} + {{#link-to 'logged-in.project.translation' project.id conflict.relatedTranslation.id class='conflictedText-textReference-link'}}{{revision.language.name}}{{/link-to}} + + + {{time-ago-in-words-tag date=conflict.relatedTranslation.updatedAt}} + + + +
    + {{conflict.relatedTranslation.correctedText}} +
    +
    + {{/if}} + + {{#if error}} +
    + {{t 'components.conflict_item.correct_error_text'}} +
    + {{/if}} +
    + +
    +
    + {{translation-edit/form + disabled=conflict.isRemoved + valueType=conflict.valueType + value=textInput + rows=3 + showTypeHints=false + onFocus=(action 'inputFocus') + onBlur=(action 'inputBlur') + onSubmit=(action 'correct') + }} +
    + + {{#if (get permissions 'correct_translation')}} + {{#async-button + onClick=(action 'correct') + loading=loading + class='button button--filled' + }} + {{inline-svg '/assets/check.svg' class='button-icon'}} + {{t 'components.conflict_item.correct_button_text'}} + {{/async-button}} + {{/if}} +
    +
    + {{/if}} +
  • diff --git a/webapp/app/pods/components/conflicts-filters/component.js b/webapp/app/pods/components/conflicts-filters/component.js new file mode 100644 index 00000000..bd3ed3ab --- /dev/null +++ b/webapp/app/pods/components/conflicts-filters/component.js @@ -0,0 +1,74 @@ +import {observer, computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {notEmpty, gt, reads} from '@ember/object/computed'; +import Component from '@ember/component'; +import {run} from '@ember/runloop'; + +const DEBOUNCE_OFFSET = 500; // ms + +// Attributes: +// query: String +// reference: String +// referenceRevisions: Array of +// document: Object +// documents: Array of +// onChangeQuery: Function +// onChangeReference: Function +// onChangeDocument: Function +export default Component.extend({ + i18n: service(), + + showReferenceRevisionsSelect: notEmpty('referenceRevisions'), + showDocumentsSelect: gt('documents.length', 1), + debouncedQuery: reads('query'), + + queryDidChanges: observer('debouncedQuery', function() { + run.debounce(this, this._debounceQuery, DEBOUNCE_OFFSET); + }), + + mappedDocuments: computed('documents.[]', function() { + const documents = this.documents.map(({id, path}) => ({ + label: path, + value: id + })); + + documents.unshift({ + label: this.i18n.t('components.conflicts_filters.document_default_option_text'), + value: null + }); + + return documents; + }), + + mappedReferenceRevisions: computed('referenceRevisions.[]', function() { + const revisions = this.referenceRevisions.map(({id, language}) => ({ + label: language.name, + value: id + })); + + revisions.unshift({ + label: this.i18n.t('components.conflicts_filters.reference_default_option_text'), + value: null + }); + + return revisions; + }), + + documentValue: computed('document', 'mappedDocuments.[]', function() { + return this.mappedDocuments.find(({value}) => value === this.document); + }), + + referenceValue: computed('reference', 'mappedReferenceRevisions.[]', function() { + return this.mappedReferenceRevisions.find(({value}) => value === this.reference); + }), + + _debounceQuery() { + this.onChangeQuery(this.debouncedQuery); + }, + + actions: { + submitForm() { + this._debounceQuery(); + } + } +}); diff --git a/webapp/app/pods/components/conflicts-filters/styles.scss b/webapp/app/pods/components/conflicts-filters/styles.scss new file mode 100644 index 00000000..b69a0399 --- /dev/null +++ b/webapp/app/pods/components/conflicts-filters/styles.scss @@ -0,0 +1,66 @@ +.subNavigation + & { + position: relative; + top: -1px; + z-index: 8; +} + +.filters-wrapper { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.filters-content { + flex-grow: 1; + margin-right: 15px; +} + +.queryForm { + position: relative; +} + +.search-icon { + position: absolute; + top: 50%; + margin-top: -10px; + left: 7px; + width: 20px; + height: 20px; + fill: #b7b7b7; +} + +.input { + @extend %textInput; + transition: $transition-speed $transition-easing; + transition-property: border-color; + width: 100%; + padding: 7px 7px 7px 30px; + font-family: $font-primary; + font-size: 14px; + color: $color-black; + + &:focus { + box-shadow: inset 0 1px 2px #f6f6f6, 0 1px 2px rgba($color-black, 0.06); + } + + &::placeholder { + color: $color-grey; + } +} + +.totalEntries { + margin-top: 10px; + color: $color-grey; + font-size: 12px; +} + +@media (max-width: ($screen-sm)) { + .filters-wrapper { + flex-direction: column; + } + + .queryForm, + .filters-content { + margin-right: 0; + } +} diff --git a/webapp/app/pods/components/conflicts-filters/template.hbs b/webapp/app/pods/components/conflicts-filters/template.hbs new file mode 100644 index 00000000..e36c89d6 --- /dev/null +++ b/webapp/app/pods/components/conflicts-filters/template.hbs @@ -0,0 +1,54 @@ +
    +
    +
    +
    + {{inline-svg '/assets/search.svg' class='search-icon'}} + + {{input + class='input' + type='text' + placeholder=(t 'components.conflicts_filters.input_placeholder_text') + value=debouncedQuery + }} +
    + +
    + {{#if showDocumentsSelect}} +
    +
    + {{#power-select + searchEnabled=false + selected=documentValue + options=mappedDocuments + onchange=(action onChangeDocument value='value') as |document| + }} + {{document.label}} + {{/power-select}} +
    +
    + {{/if}} + + {{#if showReferenceRevisionsSelect}} +
    +
    + {{#power-select + searchEnabled=false + selected=referenceValue + options=mappedReferenceRevisions + onchange=(action onChangeReference value='value') as |reference| + }} + {{reference.label}} + {{/power-select}} +
    +
    + {{/if}} +
    +
    + + {{#if meta.totalEntries}} + + {{t 'components.conflicts_filters.total_entries_count' count=meta.totalEntries}} + + {{/if}} +
    +
    diff --git a/webapp/app/pods/components/conflicts-items/component.js b/webapp/app/pods/components/conflicts-items/component.js new file mode 100644 index 00000000..bb37cb71 --- /dev/null +++ b/webapp/app/pods/components/conflicts-items/component.js @@ -0,0 +1,32 @@ +import Component from '@ember/component'; +import {computed} from '@ember/object'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// conflicts: Array of +// referenceRevision: Object (optional) +// fullscreen: Boolean +// query: String +// onCorrect: Function +// onCorrectAll: Function +export default Component.extend({ + classNameBindings: ['fullscreen'], + tagName: 'ul', + + isCorrectAllConflictLoading: false, + + toggledFullscreen: computed('fullscreen', function() { + return !this.fullscreen; + }), + + actions: { + correctAllConflicts() { + this.set('isCorrectAllConflictLoading', true); + + this.onCorrectAll().then(() => { + this.set('isCorrectAllConflictLoading', false); + }); + } + } +}); diff --git a/webapp/app/pods/components/conflicts-items/styles.scss b/webapp/app/pods/components/conflicts-items/styles.scss new file mode 100644 index 00000000..19a6434b --- /dev/null +++ b/webapp/app/pods/components/conflicts-items/styles.scss @@ -0,0 +1,19 @@ +& { + position: relative; + margin-top: 20px; + padding-top: 50px; +} + +&.fullscreen { + padding: 50px 0 0; +} + +.fullscreen-button { + padding: 5px 12px; +} + +.actions { + position: absolute; + right: 0; + top: 0; +} diff --git a/webapp/app/pods/components/conflicts-items/template.hbs b/webapp/app/pods/components/conflicts-items/template.hbs new file mode 100644 index 00000000..aaeca899 --- /dev/null +++ b/webapp/app/pods/components/conflicts-items/template.hbs @@ -0,0 +1,50 @@ +{{#if conflicts}} +
    + {{#async-button + onClick=(action 'correctAllConflicts') + loading=isCorrectAllConflictLoading + disabled=isCorrectAllConflictLoading + class='button button--green' + }} + {{inline-svg '/assets/check.svg' class='button-icon'}} + {{t 'components.conflicts_items.correct_all_button'}} + {{/async-button}} + + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + revision.id + (query-params fullscreen=toggledFullscreen) + class='button button--grey fullscreen-button' + }} + {{#if fullscreen}} + {{inline-svg '/assets/fullscreen-minimize.svg' class='button-icon fullscreen-icon'}} + {{else}} + {{inline-svg '/assets/fullscreen.svg' class='button-icon fullscreen-icon'}} + {{/if}} + {{t 'components.conflicts_items.fullscreen'}} + {{/link-to}} +
    +{{/if}} + +{{#each conflicts key='id' as |conflict|}} + {{conflict-item + fullscreen=fullscreen + revision=referenceRevision + permissions=permissions + project=project + conflict=conflict + onCorrect=onCorrect + }} +{{else if query}} + {{empty-content + iconPath='assets/empty.svg' + text=(t 'components.conflicts_items.no_translations' + query=query) + }} +{{else}} + {{empty-content + class='success' iconPath='assets/thumbs-up.svg' + text=(t 'components.conflicts_items.review_completed') + }} +{{/each}} diff --git a/webapp/app/pods/components/conflicts-page/component.js b/webapp/app/pods/components/conflicts-page/component.js new file mode 100644 index 00000000..fb10cb63 --- /dev/null +++ b/webapp/app/pods/components/conflicts-page/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + classNameBindings: ['fullscreen'] +}); diff --git a/webapp/app/pods/components/conflicts-page/styles.scss b/webapp/app/pods/components/conflicts-page/styles.scss new file mode 100644 index 00000000..be3048bf --- /dev/null +++ b/webapp/app/pods/components/conflicts-page/styles.scss @@ -0,0 +1,14 @@ +& { + background: #fff; +} + +&.fullscreen { + z-index: 200; + position: fixed; + top: 0; + left: 0; + overflow-y: scroll; + height: 100vh; + width: 100%; + padding: 15px; +} diff --git a/webapp/app/pods/components/conflicts-page/template.hbs b/webapp/app/pods/components/conflicts-page/template.hbs new file mode 100644 index 00000000..3f683f0a --- /dev/null +++ b/webapp/app/pods/components/conflicts-page/template.hbs @@ -0,0 +1,39 @@ +{{conflicts-filters + meta=translations.meta + conflicts=translations.entries + referenceRevisions=referenceRevisions + document=document + documents=documents + query=query + reference=reference + onChangeDocument=onChangeDocument + onChangeReference=onChangeReference + onChangeQuery=onChangeQuery +}} + +{{#if isLoading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/conflicts-items}} +{{else if showLoading}} + {{loading-content label=(t 'pods.project.conflicts.loading_content')}} +{{else}} + {{conflicts-items + referenceRevision=referenceRevision + revision=revision + fullscreen=fullscreen + permissions=permissions + project=project + conflicts=translations.entries + query=query + onCorrect=onCorrect + onCorrectAll=onCorrectAll + }} + + {{resource-pagination + meta=translations.meta + onSelectPage=onSelectPage + }} +{{/if}} diff --git a/webapp/app/pods/components/dashboard-features-list/component.js b/webapp/app/pods/components/dashboard-features-list/component.js new file mode 100644 index 00000000..346d8200 --- /dev/null +++ b/webapp/app/pods/components/dashboard-features-list/component.js @@ -0,0 +1,16 @@ +import {computed} from '@ember/object'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// revision: Object +// permissions: Ember Object containing +export default Component.extend({ + highlightSync: computed('revision.translationsCount', function() { + return this.revision.translationsCount <= 0; + }), + + highlightReview: computed('highlightSync', 'permissions.sync', function() { + return !this.highlightSync && this.revision.conflictsCount > 0; + }) +}); diff --git a/webapp/app/pods/components/dashboard-features-list/styles.scss b/webapp/app/pods/components/dashboard-features-list/styles.scss new file mode 100644 index 00000000..6f46127f --- /dev/null +++ b/webapp/app/pods/components/dashboard-features-list/styles.scss @@ -0,0 +1,96 @@ +& { + padding: 0 15px 0 0; + flex: 1 0 50%; + list-style: none; +} + +.item { + margin-bottom: 25px; + + &.item--highlight .item-link { + padding: 10px 15px 12px; + background: lighten($color-primary, 47%); + border-left: 2px solid $color-primary; + + .item-icon { + fill: $color-primary; + } + + .item-text { + color: darken($color-primary, 25%); + } + + .item-title { + color: $color-primary; + } + + &:focus, + &:hover { + .item-title { + color: darken($color-primary, 10%); + } + + .item-text { + color: darken($color-primary, 35%); + } + } + } +} + +.item-link { + display: block; + color: $color-grey; + text-decoration: none; + + &:focus, + &:hover { + .item-title { + color: $color-primary; + } + + .item-icon { + fill: $color-primary; + } + + .item-text { + color: darken($color-grey, 10%); + } + } +} + +.item-title { + transition: $transition-speed $transition-easing; + transition-property: color; + display: flex; + align-items: center; + padding-bottom: 5px; + color: $color-black; + font-size: 12px; + font-weight: normal; +} + +.item--highlight .item-title { + font-size: 15px; +} + +.item-icon { + transition: $transition-speed $transition-easing; + transition-property: fill; + width: 18px; + height: 18px; + margin-right: 4px; + fill: $color-black; +} + +.item-text { + transition: $transition-speed $transition-easing; + transition-property: color; + display: block; + margin-top: 5px; + color: $color-grey; + font-size: 12px; +} + +.item--highlight .item-title { + font-size: 13px; +} diff --git a/webapp/app/pods/components/dashboard-features-list/template.hbs b/webapp/app/pods/components/dashboard-features-list/template.hbs new file mode 100644 index 00000000..a0cac598 --- /dev/null +++ b/webapp/app/pods/components/dashboard-features-list/template.hbs @@ -0,0 +1,57 @@ +
      + {{#if (get permissions 'index_conflicts')}} +
    • + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + revision.id + class='item-link' + }} + + {{inline-svg 'assets/check.svg' class='item-icon'}} + {{t 'components.dashboard_master_revision.features.review.title'}} + + + {{t 'components.dashboard_master_revision.features.review.text'}} + + {{/link-to}} +
    • + {{/if}} + + {{#if (get permissions 'sync')}} +
    • + {{#link-to + 'logged-in.project.files' + project.id + class='item-link' + }} + + {{inline-svg 'assets/sync.svg' class='item-icon'}} + {{t 'components.dashboard_master_revision.features.sync.title'}} + + + {{t 'components.dashboard_master_revision.features.sync.text'}} + + {{/link-to}} +
    • + {{/if}} + + {{#if (get permissions 'index_translations')}} +
    • + {{#link-to + 'logged-in.project.revision.translations' + project.id + revision.id + class='item-link' + }} + + {{inline-svg 'assets/search.svg' class='item-icon'}} + {{t 'components.dashboard_master_revision.features.translations.title'}} + + + {{t 'components.dashboard_master_revision.features.translations.text'}} + + {{/link-to}} +
    • + {{/if}} +
    diff --git a/webapp/app/pods/components/dashboard-revision-progress/component.js b/webapp/app/pods/components/dashboard-revision-progress/component.js new file mode 100644 index 00000000..6cebf3ff --- /dev/null +++ b/webapp/app/pods/components/dashboard-revision-progress/component.js @@ -0,0 +1,32 @@ +import {computed} from '@ember/object'; +import {alias, lt, gte} from '@ember/object/computed'; +import Component from '@ember/component'; +import percentage from 'accent-webapp/component-helpers/percentage'; + +const LOW_PERCENTAGE = 50; +const HIGH_PERCENTAGE = 90; + +// Attributes: +// project: Object +// revision: Object +// permissions: Ember Object containing +export default Component.extend({ + master: alias('revision.master'), + lowPercentage: lt('correctedKeysPercentage', LOW_PERCENTAGE), // Lower than low percentage + mediumPercentage: gte('correctedKeysPercentage', LOW_PERCENTAGE), // higher or equal than low percentage + highPercentage: gte('correctedKeysPercentage', HIGH_PERCENTAGE), // higher or equal than high percentage + + classNameBindings: ['master', 'lowPercentage', 'mediumPercentage', 'highPercentage'], + + correctedKeysPercentage: computed('revision.{conflictsCount,translationsCount}', function() { + const {conflictsCount, translationsCount} = this.revision; + + return percentage(conflictsCount, translationsCount); + }), + + reviewsCount: computed('revision.{conflictsCount,translationsCount}', function() { + const {conflictsCount, translationsCount} = this.revision; + + return translationsCount - conflictsCount; + }) +}); diff --git a/webapp/app/pods/components/dashboard-revision-progress/styles.scss b/webapp/app/pods/components/dashboard-revision-progress/styles.scss new file mode 100644 index 00000000..e3b1863a --- /dev/null +++ b/webapp/app/pods/components/dashboard-revision-progress/styles.scss @@ -0,0 +1,87 @@ +& { + max-width: 330px; + margin-bottom: 20px; +} + +&.master { + .language-name { + color: darken($color-grey, 15%); + } +} + +&.low-percentage { + .reviewedStats { + color: $color-error; + } + + .container { + border-color: lighten($color-error, 47%); + background: lighten($color-error, 53%); + } + + .progress { + background: $color-error; + } +} + +&.medium-percentage { + .reviewedStats { + color: $color-warning; + } + + .container { + border-color: lighten($color-warning, 30%); + background: lighten($color-warning, 45%); + } + + .progress { + background: $color-warning; + } +} + +&.high-percentage { + .reviewedStats { + color: $color-success; + } + + .container { + border-color: lighten($color-success, 45%); + background: lighten($color-success, 53%); + } + + .progress { + background: $color-success; + } +} + +.language { + display: flex; + align-items: center; + justify-content: space-between; + color: $color-grey; +} + +.language-name { + transition: $transition-speed $transition-easing; + transition-property: color; + color: $color-grey; + font-size: 14px; + font-weight: bold; + text-decoration: none; + + &:focus, + &:hover { + color: $color-primary; + } +} + +.language-reviewedPercentage { + margin-left: 4px; + font-weight: normal; + font-size: 13px; + color: lighten($color-grey, 10%); +} + +.reviewedStats { + font-size: 12px; +} diff --git a/webapp/app/pods/components/dashboard-revision-progress/template.hbs b/webapp/app/pods/components/dashboard-revision-progress/template.hbs new file mode 100644 index 00000000..e499cc65 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revision-progress/template.hbs @@ -0,0 +1,28 @@ +
    + + {{#if (get permissions 'index_translations')}} + {{#link-to + 'logged-in.project.revision.translations' + project.id + revision.id + class='language-name' + }} + {{revision.language.name}} + {{correctedKeysPercentage}}% + {{/link-to}} + {{else}} + + {{revision.language.name}} + {{correctedKeysPercentage}}% + + {{/if}} + + + {{reviewsCount}} + / + {{revision.translationsCount}} + + + + {{review-progress-bar correctedKeysPercentage=correctedKeysPercentage}} +
    diff --git a/webapp/app/pods/components/dashboard-revisions/component.js b/webapp/app/pods/components/dashboard-revisions/component.js new file mode 100644 index 00000000..fd398327 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/component.js @@ -0,0 +1,58 @@ +import {computed} from '@ember/object'; +import {lt, gte} from '@ember/object/computed'; +import Component from '@ember/component'; +import percentage from 'accent-webapp/component-helpers/percentage'; + +const LOW_PERCENTAGE = 50; +const HIGH_PERCENTAGE = 90; + +// Attributes: +// project: Object +// document: Object +// revisions: Array of +// permissions: Ember Object containing +// onCorrectAllConflicts: Function +// onUncorrectAllConflicts: Function +export default Component.extend({ + lowPercentage: lt('reviewedPercentage', LOW_PERCENTAGE), // Lower than low percentage + mediumPercentage: gte('reviewedPercentage', LOW_PERCENTAGE), // higher or equal than low percentage + highPercentage: gte('reviewedPercentage', HIGH_PERCENTAGE), // higher or equal than high percentage + + classNameBindings: ['lowPercentage', 'mediumPercentage', 'highPercentage'], + + masterRevision: computed('revisions', function() { + return this.revisions.find(revision => revision.isMaster); + }), + + slaveRevisions: computed('revisions', function() { + return this.revisions.filter(revision => revision !== this.masterRevision); + }), + + totalStrings: computed('revisions.[]', function() { + return this.revisions.reduce((memo, revision) => { + return memo + revision.translationsCount; + }, 0); + }), + + totalConflicts: computed('revisions.[]', function() { + return this.revisions.reduce((memo, revision) => { + return memo + revision.conflictsCount; + }, 0); + }), + + totalReviewed: computed('revisions.[]', function() { + return this.revisions.reduce((memo, revision) => { + return memo + (revision.translationsCount - revision.conflictsCount); + }, 0); + }), + + reviewCompleted: gte('reviewedPercentage', 100), + + reviewedPercentage: computed('totalConflicts', 'totalStrings', function() { + return percentage(this.totalStrings - this.totalConflicts, this.totalStrings); + }), + + conflictedPercentage: computed('totalReviewed', 'totalStrings', function() { + return percentage(this.totalStrings - this.totalReviewed, this.totalStrings); + }) +}); diff --git a/webapp/app/pods/components/dashboard-revisions/item/component.js b/webapp/app/pods/components/dashboard-revisions/item/component.js new file mode 100644 index 00000000..bf1122a0 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/item/component.js @@ -0,0 +1,67 @@ +import {computed} from '@ember/object'; +import {alias, lt, gte, or, gt} from '@ember/object/computed'; +import Component from '@ember/component'; +import percentage from 'accent-webapp/component-helpers/percentage'; + +const LOW_PERCENTAGE = 50; +const HIGH_PERCENTAGE = 90; + +// Attributes: +// project: Object +// revision: Object +// permissions: Ember Object containing +// onCorrectAllConflicts: Function +// onUncorrectAllConflicts: Function +export default Component.extend({ + showActions: false, + master: alias('revision.isMaster'), + lowPercentage: lt('correctedKeysPercentage', LOW_PERCENTAGE), // Lower than low percentage + mediumPercentage: gte('correctedKeysPercentage', LOW_PERCENTAGE), // higher or equal than low percentage + highPercentage: gte('correctedKeysPercentage', HIGH_PERCENTAGE), // higher or equal than high percentage + + classNameBindings: ['master', 'lowPercentage', 'mediumPercentage', 'highPercentage'], + + isCorrectAllConflictLoading: false, + isUncorrectAllConflictLoading: false, + + isAnyActionsLoading: or('isCorrectAllConflictLoading', 'isUncorrectAllConflictLoading'), + + showCorrectAllAction: lt('correctedKeysPercentage', 100), + showUncorrectAllAction: gt('correctedKeysPercentage', 0), + + correctedKeysPercentage: computed('revision.{conflictsCount,translationsCount}', function() { + return percentage(this.revision.translationsCount - this.revision.conflictsCount, this.revision.translationsCount); + }), + + reviewsCount: computed('revision.{conflictsCount,translationsCount}', function() { + const {conflictsCount, translationsCount} = this.revision; + + return translationsCount - conflictsCount; + }), + + actions: { + toggleShowActions() { + this.toggleProperty('showActions'); + }, + + correctAllConflicts() { + this.set('isCorrectAllConflictLoading', true); + + this.onCorrectAllConflicts(this.revision).then(() => this._onCorrectAllConflictsDone()); + }, + + uncorrectAllConflicts() { + this.set('isUncorrectAllConflictLoading', true); + + this.onUncorrectAllConflicts(this.revision).then(() => this._onUncorrectAllConflictsDone()); + } + }, + + _onCorrectAllConflictsDone() { + this.set('isCorrectAllConflictLoading', false); + }, + + _onUncorrectAllConflictsDone() { + this.set('isUncorrectAllConflictLoading', false); + } +}); diff --git a/webapp/app/pods/components/dashboard-revisions/item/styles.scss b/webapp/app/pods/components/dashboard-revisions/item/styles.scss new file mode 100644 index 00000000..3de90aa6 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/item/styles.scss @@ -0,0 +1,176 @@ +& { + transition: $transition-speed $transition-easing; + transition-property: border-color; + position: relative; + flex: 1 1 calc(50% - 10px); + justify-content: space-between; + max-width: 50%; + margin-bottom: 20px; + margin-right: 10px; + padding: 8px 15px 12px; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(#000, 0.15); + + &:nth-child(even) { + flex: 1 1 50%; + margin-right: 0; + } + + &:focus, + &:hover { + border-color: #ddd; + + .actionsButton { + opacity: 1; + } + } +} + +&.low-percentage { + .language-reviewedPercentage, + .reviewedStats { + color: $color-error; + } + + .container { + border-color: lighten($color-error, 47%); + background: lighten($color-error, 53%); + } + + .progress { + background: $color-error; + } +} + +&.medium-percentage { + .language-reviewedPercentage, + .reviewedStats { + color: $color-warning; + } + + .container { + border-color: lighten($color-warning, 30%); + background: lighten($color-warning, 45%); + } + + .progress { + background: $color-warning; + } +} + +&.high-percentage { + .language-reviewedPercentage, + .reviewedStats { + color: $color-success; + } + + .container { + border-color: lighten($color-success, 45%); + background: lighten($color-success, 53%); + } + + .progress { + background: $color-success; + } +} + +&.master { + flex: 1 1 auto; + max-width: none; + + .language-name { + font-size: 14px; + } +} + +.language { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 6px; + color: $color-grey; +} + +.language-name { + transition: $transition-speed $transition-easing; + transition-property: color; + color: darken($color-grey, 10%); + font-size: 12px; + text-decoration: none; + + &:focus, + &:hover { + color: $color-primary; + } +} + +.language-reviewedPercentage { + display: block; + margin-top: 5px; + font-weight: normal; + font-size: 25px; + color: lighten($color-grey, 10%); +} + +.reviewedStats { + font-size: 12px; + font-family: $font-monospace; +} + +.actionsButton { + transition: $transition-speed $transition-easing; + transition-property: opacity; + position: absolute; + right: 7px; + top: 6px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: #fff; + opacity: 0; + cursor: pointer; + + &:focus, + &:hover { + .actionsButton-icon { + fill: $color-primary; + } + } +} + +.actionsButton-icon { + transition: $transition-speed $transition-easing; + transition-property: fill; + width: 20px; + height: 20px; +} + +.actions { + margin-top: 10px; +} + +.actionItem-text { + margin-bottom: 7px; + font-size: 12px; + color: $color-grey; +} + +.actionItem-button { + margin-bottom: 10px; + + &:last-of-type { + margin-bottom: 0; + } +} + +@media (max-width: ($screen-sm)) { + .language-reviewedPercentage { + font-size: 18px; + } + + .reviewedStats { + display: none; + } +} diff --git a/webapp/app/pods/components/dashboard-revisions/item/template.hbs b/webapp/app/pods/components/dashboard-revisions/item/template.hbs new file mode 100644 index 00000000..c693f721 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/item/template.hbs @@ -0,0 +1,60 @@ +
    + + {{inline-svg 'assets/gear.svg' class='actionsButton-icon'}} + + + + {{#if (get permissions 'index_translations')}} + {{#link-to + 'logged-in.project.revision.translations' + project.id + revision.id + class='language-name' + }} + {{revision.language.name}} + {{correctedKeysPercentage}}% + {{/link-to}} + {{else}} + + {{revision.language.name}} + {{correctedKeysPercentage}}% + + {{/if}} + + + {{reviewsCount}} + / + {{revision.translationsCount}} + + + + {{review-progress-bar correctedKeysPercentage=correctedKeysPercentage}} + + {{#if showActions}} +
    + {{#if showCorrectAllAction}} + {{#async-button + onClick=(action 'correctAllConflicts') + loading=isCorrectAllConflictLoading + disabled=isAnyActionsLoading + class='button button--green actionItem-button' + }} + {{inline-svg '/assets/check.svg' class='button-icon'}} + {{t 'components.dashboard_revisions.item.correct_all_button'}} + {{/async-button}} + {{/if}} + + {{#if showUncorrectAllAction}} + {{#async-button + onClick=(action 'uncorrectAllConflicts') + loading=isUncorrectAllConflictLoading + disabled=isAnyActionsLoading + class='button button--red actionItem-button' + }} + {{inline-svg '/assets/revert.svg' class='button-icon'}} + {{t 'components.dashboard_revisions.item.uncorrect_all_button'}} + {{/async-button}} + {{/if}} +
    + {{/if}} +
    diff --git a/webapp/app/pods/components/dashboard-revisions/styles.scss b/webapp/app/pods/components/dashboard-revisions/styles.scss new file mode 100644 index 00000000..ca6010e0 --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/styles.scss @@ -0,0 +1,191 @@ +&.low-percentage { + .numberStat-reviewCompleted, + .numberStat-reviewPercentage { + color: $color-error; + } +} + +&.medium-percentage { + .numberStat-reviewCompleted, + .numberStat-reviewPercentage { + color: $color-warning; + } +} + +&.high-percentage { + .numberStat-reviewCompleted, + .numberStat-reviewPercentage { + color: $color-success; + } +} + +& { + display: flex; +} + +& > .content { + display: flex; + align-items: flex-start; + flex-direction: column; + width: 60%; + margin-right: 40px; +} + +.numberStat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + margin: 30px auto 60px; +} + +.numberStat-reviewPercentage { + font-size: 42px; + font-weight: 400; +} + +.numberStat-reviewPercentage-label { + margin-left: -5px; + font-size: 12px; +} + +.numberStat-totalKeys { + display: block; + font-size: 15px; + font-family: $font-monospace; + color: #ccc; +} + +.numberStat-totalKeys-label { + font-size: 11px; +} + +.numberStat-reviewCompleted { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 10px; + font-size: 22px; +} + +.numberStat-reviewCompleted-successIcon { + display: block; + fill: rgba($color-success, 0.6); + width: 38px; + height: 38px; +} + +.stats { + display: flex; + flex-direction: column; + width: 100%; +} + +.stats-title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 5px; + font-size: 14px; + font-weight: bold; + color: $color-black; +} + +.slaves { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.empty-slaves-button { + display: block; + width: 100%; + padding: 15px; + background: #fff; + box-shadow: 0 2px 4px rgba(#000, 0.15); + border-radius: 2px; + border-left: 2px solid lighten($color-primary, 30%); + color: $color-primary; + text-decoration: none; + transition: box-shadow 0.2s ease-in-out; + + &:hover, + &:focus { + box-shadow: 0 4px 12px rgba(#000, 0.15); + } +} + +.empty-slaves-button-action { + display: flex; + align-items: center; + font-size: 13px; +} + +.empty-slaves-button-icon { + width: 20px; + height: 20px; + margin-right: 5px; + fill: $color-primary; +} + +.empty-slaves-button-text { + display: block; + margin-top: 10px; + font-size: 11px; + color: #aaa; +} + +.master { + margin: 0 0 20px; +} + +.stats-title-links { + display: flex; + justify-content: flex-end; + + .button { + margin-left: 10px; + font-size: 11px; + } +} + +.activities { + display: flex; + flex-direction: column; + max-width: 380px; +} + +.activities-title { + display: flex; + align-items: center; + margin-bottom: 15px; + font-size: 14px; + font-weight: 300; + color: #666; +} + +.activities-title-icon { + fill: #bbb; + width: 15px; + height: 15px; + margin-right: 6px; +} + +.activities-viewMoreButton { + display: block; + margin-top: 15px; + text-align: center; +} + +@media (max-width: ($screen-md)) { + & > .content { + width: 100%; + margin-right: 0; + } + + .activities { + display: none; + } +} diff --git a/webapp/app/pods/components/dashboard-revisions/template.hbs b/webapp/app/pods/components/dashboard-revisions/template.hbs new file mode 100644 index 00000000..c7a4dcac --- /dev/null +++ b/webapp/app/pods/components/dashboard-revisions/template.hbs @@ -0,0 +1,151 @@ +
    + {{#if project.lastSyncedAt}} +
    + {{#if reviewCompleted}} + + {{inline-svg '/assets/thumbs-up.svg' class='numberStat-reviewCompleted-successIcon'}} + {{t 'components.dashboard_revisions.all_reviewed'}} + + {{else}} + + {{reviewedPercentage}}% + {{t 'components.dashboard_revisions.reviewed'}} + + {{/if}} + + + {{totalReviewed}} / {{totalStrings}} + {{t 'components.dashboard_revisions.strings'}} + +
    + +
    +

    + {{t 'components.dashboard_revisions.master'}}: + + +

    + +
    + {{dashboard-revisions/item + project=project + revision=masterRevision + permissions=permissions + onCorrectAllConflicts=onCorrectAllConflicts + onUncorrectAllConflicts=onUncorrectAllConflicts + }} +
    + + {{#if slaveRevisions}} +

    + {{t 'components.dashboard_revisions.slaves'}}: + + +

    + +
    + {{#each slaveRevisions key='id' as |revision|}} + {{dashboard-revisions/item + project=project + revision=revision + permissions=permissions + onCorrectAllConflicts=onCorrectAllConflicts + onUncorrectAllConflicts=onUncorrectAllConflicts + }} + {{/each}} +
    + {{else}} +
    + {{#if (get permissions 'create_slave')}} + {{#link-to + 'logged-in.project.edit.manage-languages' + project.id + class='empty-slaves-button' + }} + + {{inline-svg 'assets/add.svg' class='empty-slaves-button-icon'}} + {{t 'components.dashboard_revisions.new_language_link_title'}} + + + + {{t 'components.dashboard_revisions.new_language_link_text'}} + + {{/link-to}} + {{/if}} +
    + {{/if}} +
    + {{else}} + {{welcome-project project=project}} + {{/if}} +
    + +{{#if activities}} +
    +

    + {{inline-svg 'assets/activity.svg' class='activities-title-icon'}} + {{t 'components.dashboard_revisions.activities_title'}} +

    + + {{project-activities-list + permissions=permissions + activities=activities + project=project + compact=true + }} + + {{#link-to + 'logged-in.project.activities' + project.id + class='button button--filled button--white button--borderLess activities-viewMoreButton' + }} + {{t 'components.dashboard_revisions.view_more_activities'}} + {{/link-to}} +
    +{{/if}} diff --git a/webapp/app/pods/components/date-tag/component.js b/webapp/app/pods/components/date-tag/component.js new file mode 100644 index 00000000..fe5ad1d8 --- /dev/null +++ b/webapp/app/pods/components/date-tag/component.js @@ -0,0 +1,24 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; +import dateFormat from 'npm:date-fns/format'; + +// Attributes: +// date: String +export default Component.extend({ + i18n: service(), + + tagName: 'span', + + // The follow property returns a formatted date like this: 2016-02-03T11:02:34 + formattedDatetime: computed('date', function() { + const format = this.i18n.t('components.date_tag.formatted_date_time_format').toString(); + return dateFormat(new Date(this.date), format); + }), + + // The follow property returns a formatted date like this: February 3rd 2016, 11:02:34 + humanizedDate: computed('date', function() { + const format = this.i18n.t('components.date_tag.humanized_date_title_format').toString(); + return dateFormat(new Date(this.date), format); + }) +}); diff --git a/webapp/app/pods/components/date-tag/template.hbs b/webapp/app/pods/components/date-tag/template.hbs new file mode 100644 index 00000000..c7485417 --- /dev/null +++ b/webapp/app/pods/components/date-tag/template.hbs @@ -0,0 +1,3 @@ + diff --git a/webapp/app/pods/components/documents-add-button/styles.scss b/webapp/app/pods/components/documents-add-button/styles.scss new file mode 100644 index 00000000..fabb070a --- /dev/null +++ b/webapp/app/pods/components/documents-add-button/styles.scss @@ -0,0 +1,26 @@ +.button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: 100%; + padding: 20px; + background: #fff; + border: 1px solid #eee; + border-radius: 3px; + color: $color-primary; + font-weight: normal; + font-size: 18px; + + &:focus, + &:hover { + background: lighten($color-primary, 50%); + } +} + +.button-icon { + margin: 0 0 10px; + width: 30px; + height: 30px; + fill: $color-primary; +} diff --git a/webapp/app/pods/components/documents-add-button/template.hbs b/webapp/app/pods/components/documents-add-button/template.hbs new file mode 100644 index 00000000..354375aa --- /dev/null +++ b/webapp/app/pods/components/documents-add-button/template.hbs @@ -0,0 +1,8 @@ +{{#link-to + 'logged-in.project.files.new-sync' + project.id + class='button' +}} + {{inline-svg '/assets/add.svg' class='button-icon'}} + {{t 'components.documents_add_button.link'}} +{{/link-to}} diff --git a/webapp/app/pods/components/documents-list/component.js b/webapp/app/pods/components/documents-list/component.js new file mode 100644 index 00000000..d5db1600 --- /dev/null +++ b/webapp/app/pods/components/documents-list/component.js @@ -0,0 +1,10 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// documents: Array of +// onDelete: Function +export default Component.extend({ + tagName: 'ul' +}); diff --git a/webapp/app/pods/components/documents-list/item/component.js b/webapp/app/pods/components/documents-list/item/component.js new file mode 100644 index 00000000..5a7754e0 --- /dev/null +++ b/webapp/app/pods/components/documents-list/item/component.js @@ -0,0 +1,34 @@ +import {computed} from '@ember/object'; +import {not} from '@ember/object/computed'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// document: Object +// onDelete: Function +export default Component.extend({ + globalState: service('global-state'), + + tagName: 'li', + + classNames: ['item'], + + isDeleting: false, + canDeleteFile: not('project.lockedFileOperations'), + + documentFormatItem: computed('document.format', function() { + if (!this.globalState.documentFormats) return {}; + + return this.globalState.documentFormats.find(({slug}) => slug === this.document.format); + }), + + actions: { + deleteFile() { + this.set('isDeleting', true); + + this.onDelete(this.document).then(() => this.set('isDeleting', false)); + } + } +}); diff --git a/webapp/app/pods/components/documents-list/item/template.hbs b/webapp/app/pods/components/documents-list/item/template.hbs new file mode 100644 index 00000000..4a49fbc9 --- /dev/null +++ b/webapp/app/pods/components/documents-list/item/template.hbs @@ -0,0 +1,53 @@ +
    +

    {{document.path}}

    +

    .{{documentFormatItem.extension}}

    +
    + + diff --git a/webapp/app/pods/components/documents-list/styles.scss b/webapp/app/pods/components/documents-list/styles.scss new file mode 100644 index 00000000..62f3242b --- /dev/null +++ b/webapp/app/pods/components/documents-list/styles.scss @@ -0,0 +1,93 @@ +& { + width: 100%; + margin-top: 25px; +} + +.item { + position: relative; + margin-bottom: 20px; + padding: 20px; + width: 100%; + border: 1px solid #eee; + border-radius: 3px; + + &:focus, + &:hover { + .deleteButton { + display: inline-block; + } + } +} + +.item-title { + display: inline-block; + margin-bottom: 7px; + font-size: 16px; + font-weight: bold; +} + +.item-subtitle { + display: inline-block; + margin-left: 5px; + font-size: 12px; + font-style: italic; + font-weight: normal; + color: #bbb; +} + +.item-subtitle-label { + font-size: 13px; + font-weight: normal; +} + +.deleteButton { + display: none; + position: absolute; + top: 10px; + right: 0; + font-size: 11px; + + .button-icon { + width: 12px; + height: 12px; + } +} + +.stats { + position: relative; + left: -21px; + display: inline-block; + margin-bottom: 10px; + padding: 6px 10px 5px 20px; + border-radius: 3px 0 3px 0; + border: 1px solid #eee; + border-top: 0; + background: #fafafa; + font-size: 11px; + color: $color-grey; +} + +.stats-item { + margin-right: 10px; + padding-left: 10px; + border-left: 1px solid #ddd; + + &:first-of-type { + font-size: 12px; + border-color: transparent; + padding-left: 0; + color: darken($color-grey, 5%); + } +} + +.links { + margin-top: 10px; + + .button { + margin-right: 6px; + + &.button--borderLess:first-of-type:last-of-type { + margin-left: -14px; + } + } +} diff --git a/webapp/app/pods/components/documents-list/template.hbs b/webapp/app/pods/components/documents-list/template.hbs new file mode 100644 index 00000000..9626a0d4 --- /dev/null +++ b/webapp/app/pods/components/documents-list/template.hbs @@ -0,0 +1,8 @@ +{{#each documents key='id' as |document|}} + {{documents-list/item + permissions=permissions + document=document + onDelete=onDelete + project=project + }} +{{/each}} diff --git a/webapp/app/pods/components/dummy-login-form/component.js b/webapp/app/pods/components/dummy-login-form/component.js new file mode 100644 index 00000000..d5763793 --- /dev/null +++ b/webapp/app/pods/components/dummy-login-form/component.js @@ -0,0 +1,16 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// onDummyLogin: Function +export default Component.extend({ + session: service('session'), + + email: '', + + actions: { + submit() { + this.onDummyLogin(this.email); + } + } +}); diff --git a/webapp/app/pods/components/dummy-login-form/styles.scss b/webapp/app/pods/components/dummy-login-form/styles.scss new file mode 100644 index 00000000..a89c0277 --- /dev/null +++ b/webapp/app/pods/components/dummy-login-form/styles.scss @@ -0,0 +1,62 @@ +& { + max-width: 500px; + margin: 100px auto 30px; + padding: 20px; + box-shadow: 0 3px 21px rgba(#000, 0.07); + border: 1px solid #eee; + background: #fafafa; + text-align: center; +} + +.title { + margin-bottom: 15px; + font-weight: 900; + font-size: 19px; +} + +.warning { + display: inline-block; + padding: 10px 15px; + margin-bottom: 20px; + background: lighten($color-error, 40%); + font-weight: bold; + font-size: 12px; + color: $color-error; +} + +.subtitle { + margin: 0 15px 25px; + font-size: 13px; + font-style: italic; + color: #555; +} + +.form { + display: flex; + align-items: stretch; +} + +.textInput { + @extend %textInput; + flex: 1 1 auto; + padding: 10px; + margin: 0 0 0 0; + border-radius: 3px 0 0 3px; + border-right: 0; + font-size: 13px; + + &:focus { + border-right: 0; + } +} + +.dummyLoginButton { + flex: 0 1 auto; + border-radius: 0 3px 3px 0; +} + +@media (max-width: ($screen-sm)) { + & { + margin-top: 30px; + } +} diff --git a/webapp/app/pods/components/dummy-login-form/template.hbs b/webapp/app/pods/components/dummy-login-form/template.hbs new file mode 100644 index 00000000..0aaf4f72 --- /dev/null +++ b/webapp/app/pods/components/dummy-login-form/template.hbs @@ -0,0 +1,17 @@ +

    {{t 'components.dummy_login_form.title'}}

    + +
    {{t 'components.dummy_login_form.warning'}}
    + +

    {{t 'components.dummy_login_form.subtitle'}}

    + +
    + {{input + value=email + class='textInput' + }} + + +
    diff --git a/webapp/app/pods/components/empty-content/component.js b/webapp/app/pods/components/empty-content/component.js new file mode 100644 index 00000000..2c7ace2d --- /dev/null +++ b/webapp/app/pods/components/empty-content/component.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +// Attributes: +// iconPath: String +// text: String +// block: Yield template (optional) +export default Component.extend(); diff --git a/webapp/app/pods/components/empty-content/styles.scss b/webapp/app/pods/components/empty-content/styles.scss new file mode 100644 index 00000000..fe9be21f --- /dev/null +++ b/webapp/app/pods/components/empty-content/styles.scss @@ -0,0 +1,48 @@ +& { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 30px; + color: rgba($color-black, 0.4); + font-size: 18px; + font-weight: 300; + font-style: italic; + text-align: center; + + &.error { + .icon { + fill: $color-error; + opacity: 0.2; + width: 100px; + height: 100px; + } + } + + &.success { + color: $color-primary; + + .icon { + fill: $color-primary; + width: 100px; + height: 100px; + } + } +} + +.icon { + display: block; + width: 50px; + height: 50px; + margin-bottom: 10px; +} + +.link { + color: $color-primary; + text-decoration: none; + + &:focus, + &:hover { + text-decoration: underline; + } +} diff --git a/webapp/app/pods/components/empty-content/template.hbs b/webapp/app/pods/components/empty-content/template.hbs new file mode 100644 index 00000000..4369cf5f --- /dev/null +++ b/webapp/app/pods/components/empty-content/template.hbs @@ -0,0 +1,6 @@ +{{#if hasBlock}} + {{yield}} +{{else}} + {{inline-svg iconPath class='icon'}} + {{text}} +{{/if}} diff --git a/webapp/app/pods/components/error-section/component.js b/webapp/app/pods/components/error-section/component.js new file mode 100644 index 00000000..787525db --- /dev/null +++ b/webapp/app/pods/components/error-section/component.js @@ -0,0 +1,9 @@ +import Component from '@ember/component'; + +// Attributes: +// status: String +// title: String +// text: String +// isAuthenticated: Boolean +// onLogin: Function +export default Component.extend(); diff --git a/webapp/app/pods/components/error-section/styles.scss b/webapp/app/pods/components/error-section/styles.scss new file mode 100644 index 00000000..8ec0455b --- /dev/null +++ b/webapp/app/pods/components/error-section/styles.scss @@ -0,0 +1,67 @@ +& { + max-width: 400px; + margin: 100px auto 30px; + padding: 20px 20px 40px; + box-shadow: 0 3px 21px rgba(#000, 0.07); + border: 1px solid #eee; + background: #fafafa; + text-align: center; +} + +.logo { + display: block; + width: 30px; + height: 30px; + margin: 0 auto 20px; + fill: #eee; +} + +.header { + display: flex; + align-items: center; + justify-content: center; +} + +.status { + margin-right: 15px; + text-shadow: 0 1px 2px rgba($color-primary, 0.3); + font-family: $font-monospace; + font-size: 30px; + font-weight: 300; + color: $color-primary; +} + +.title { + font-family: $font-monospace; + font-size: 14px; + font-style: italic; + font-weight: 300; + color: rgba($color-primary, 0.7); +} + +.text { + max-width: 220px; + margin: 10px auto 50px; + padding-top: 20px; + border-top: 1px solid #eee; + font-size: 15px; + color: #555; +} + +.link { + font-size: 13px; + color: #ccc; + text-decoration: none; + + &:focus, + &:hover { + color: $color-primary; + text-decoration: underline; + } +} + +.or { + margin: 0 10px; + font-size: 12px; + color: #bbb; +} diff --git a/webapp/app/pods/components/error-section/template.hbs b/webapp/app/pods/components/error-section/template.hbs new file mode 100644 index 00000000..81188e8c --- /dev/null +++ b/webapp/app/pods/components/error-section/template.hbs @@ -0,0 +1,22 @@ +{{inline-svg 'assets/logo-bw.svg' class='logo'}} + +
    +

    {{status}}

    +

    {{title}}

    +
    + +

    {{text}}

    + +{{#if isAuthenticated}} + + + + {{t 'components.error_section.or'}} + +{{/if}} + +{{#link-to 'login' class='link'}} + {{t 'components.error_section.return'}} +{{/link-to}} diff --git a/webapp/app/pods/components/file-export/component.js b/webapp/app/pods/components/file-export/component.js new file mode 100644 index 00000000..69eba483 --- /dev/null +++ b/webapp/app/pods/components/file-export/component.js @@ -0,0 +1,31 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// document: Object +// revision: Object +// documentFormat: String +// orderBy: String +export default Component.extend({ + exporter: service(), + + content: '', + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.revision && !this.revisions) return; + if (!this.document) return; + + const revision = this.revision || this.revisions[0]; + + this.exporter + .export({ + revision, + ...this.getProperties('project', 'document', 'version', 'documentFormat', 'orderBy') + }) + .then(data => this.set('content', data)) + .then(data => this.onFileLoaded(data)); + } +}); diff --git a/webapp/app/pods/components/file-export/template.hbs b/webapp/app/pods/components/file-export/template.hbs new file mode 100644 index 00000000..b92f6522 --- /dev/null +++ b/webapp/app/pods/components/file-export/template.hbs @@ -0,0 +1 @@ +{{content}} diff --git a/webapp/app/pods/components/file-input/component.js b/webapp/app/pods/components/file-input/component.js new file mode 100644 index 00000000..a4ecfbd6 --- /dev/null +++ b/webapp/app/pods/components/file-input/component.js @@ -0,0 +1,16 @@ +import Component from '@ember/component'; +import Evented from '@ember/object/evented'; + +const attributeBindings = ['name', 'disabled', 'form', 'type', 'accept', 'autofocus', 'required', 'multiple']; + +// Attributes +// onChange: Function +export default Component.extend(Evented, { + tagName: 'input', + type: 'file', + attributeBindings, + + change(event) { + this.onChange(event.target.files); + } +}); diff --git a/webapp/app/pods/components/flash-messages-list/component.js b/webapp/app/pods/components/flash-messages-list/component.js new file mode 100644 index 00000000..d7002476 --- /dev/null +++ b/webapp/app/pods/components/flash-messages-list/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +// Attribute +// flashMessages: Array of from ember-cli-flash lib +export default Component.extend(); diff --git a/webapp/app/pods/components/flash-messages-list/styles.scss b/webapp/app/pods/components/flash-messages-list/styles.scss new file mode 100644 index 00000000..0c5069dd --- /dev/null +++ b/webapp/app/pods/components/flash-messages-list/styles.scss @@ -0,0 +1,6 @@ +& { + position: fixed; + bottom: 10px; + right: 30px; + z-index: 12; +} diff --git a/webapp/app/pods/components/flash-messages-list/template.hbs b/webapp/app/pods/components/flash-messages-list/template.hbs new file mode 100644 index 00000000..4954cfec --- /dev/null +++ b/webapp/app/pods/components/flash-messages-list/template.hbs @@ -0,0 +1,5 @@ +
    + {{#each flashMessages.queue as |flash|}} + {{acc-flash-message flash=flash}} + {{/each}} +
    diff --git a/webapp/app/pods/components/google-login-form/component.js b/webapp/app/pods/components/google-login-form/component.js new file mode 100644 index 00000000..9f80a133 --- /dev/null +++ b/webapp/app/pods/components/google-login-form/component.js @@ -0,0 +1,25 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// onGoogleLogin: Function +export default Component.extend({ + session: service('session'), + + didInsertElement() { + const loginButton = document.querySelector('.googleLoginButton'); + + if (loginButton) { + this.session.googleAuth.attachClickHandler( + loginButton, + {}, + googleUser => this._authSuccess(googleUser), + error => window.console.error(error) + ); + } + }, + + _authSuccess(googleUser) { + this.onGoogleLogin(googleUser); + } +}); diff --git a/webapp/app/pods/components/google-login-form/styles.scss b/webapp/app/pods/components/google-login-form/styles.scss new file mode 100644 index 00000000..6373b84c --- /dev/null +++ b/webapp/app/pods/components/google-login-form/styles.scss @@ -0,0 +1,45 @@ +& { + max-width: 500px; + margin: 100px auto 30px; + padding: 20px; + box-shadow: 0 3px 21px rgba(#000, 0.07); + border: 1px solid #eee; + background: #fafafa; + text-align: center; +} + +.title { + margin-bottom: 15px; + font-weight: 900; + font-size: 19px; +} + +.subtitle { + margin: 0 15px 15px; + font-size: 14px; +} + +.googleLogo { + width: 30px; + margin-right: 10px; +} + +.googleLoginButton { + margin-top: 10px; + padding: 7px 40px 7px 20px; + background: #4d90fe; + border: 1px solid darken(#4d90fe, 5%); + font-family: arial, sans-serif; + font-size: 14px; + + &:focus, + &:hover { + background: darken(#4d90fe, 8%); + } +} + +@media (max-width: ($screen-sm)) { + & { + margin-top: 30px; + } +} diff --git a/webapp/app/pods/components/google-login-form/template.hbs b/webapp/app/pods/components/google-login-form/template.hbs new file mode 100644 index 00000000..5d0557b4 --- /dev/null +++ b/webapp/app/pods/components/google-login-form/template.hbs @@ -0,0 +1,7 @@ +

    {{t 'components.google_login_form.title'}}

    +

    {{t 'components.google_login_form.subtitle'}}

    + + diff --git a/webapp/app/pods/components/loading-content/component.js b/webapp/app/pods/components/loading-content/component.js new file mode 100644 index 00000000..478c0e57 --- /dev/null +++ b/webapp/app/pods/components/loading-content/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +// Attributes: +// label: String +export default Component.extend(); diff --git a/webapp/app/pods/components/loading-content/styles.scss b/webapp/app/pods/components/loading-content/styles.scss new file mode 100644 index 00000000..25ed4c1e --- /dev/null +++ b/webapp/app/pods/components/loading-content/styles.scss @@ -0,0 +1,15 @@ +& { + display: flex; + position: relative; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 70px; + padding: 40px 0; +} + +.label { + color: $color-grey; + font-size: 14px; + font-style: italic; +} diff --git a/webapp/app/pods/components/loading-content/template.hbs b/webapp/app/pods/components/loading-content/template.hbs new file mode 100644 index 00000000..d4623aa2 --- /dev/null +++ b/webapp/app/pods/components/loading-content/template.hbs @@ -0,0 +1,9 @@ +{{spin-spinner + lines=9 + length=7 + color='#3dbc87' + radius=8 + width=2 +}} + +{{label}} diff --git a/webapp/app/pods/components/operations-peek/component.js b/webapp/app/pods/components/operations-peek/component.js new file mode 100644 index 00000000..79fd3768 --- /dev/null +++ b/webapp/app/pods/components/operations-peek/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +// Attributes +// revisionOperations: Array of Ember.Object containing and +export default Component.extend(); diff --git a/webapp/app/pods/components/operations-peek/item/component.js b/webapp/app/pods/components/operations-peek/item/component.js new file mode 100644 index 00000000..972c6b20 --- /dev/null +++ b/webapp/app/pods/components/operations-peek/item/component.js @@ -0,0 +1,35 @@ +import Component from '@ember/component'; + +// Attributes +// revisionOperation: Object containing the and array of +export default Component.extend({ + showStats: true, + showOperations: false, + hideDetails: false, + + actions: { + showStats() { + this.setProperties({ + showStats: true, + showOperations: false, + hideDetails: false + }); + }, + + showOperations() { + this.setProperties({ + showStats: false, + showOperations: true, + hideDetails: false + }); + }, + + hideDetails() { + this.setProperties({ + showStats: false, + showOperations: false, + hideDetails: true + }); + } + } +}); diff --git a/webapp/app/pods/components/operations-peek/item/styles.scss b/webapp/app/pods/components/operations-peek/item/styles.scss new file mode 100644 index 00000000..80ffbfa5 --- /dev/null +++ b/webapp/app/pods/components/operations-peek/item/styles.scss @@ -0,0 +1,104 @@ +& { + margin-bottom: 15px; +} + +.languageHeader { + display: flex; + justify-content: space-between; +} + +.languageHeader-title { + font-size: 15px; + font-weight: bold; +} + +.languageHeader-displayOptions-button { + padding: 0 10px; + background: none; + border-right: 1px solid $color-border; + color: $color-grey; + font-size: 13px; + + &:last-of-type { + border-right-color: transparent; + } +} + +.languageHeader-displayOptions-button[disabled] { + color: $color-primary; +} + +.statsList, +.operationsList { + margin: 10px 0; + background: $color-white; + box-shadow: 0 0 7px rgba($color-black, 0.06); + border: 1px solid $color-border; +} + +.stat { + padding: 5px 10px; + + &:first-of-type { + padding-top: 10px; + } + + &:last-of-type { + padding-bottom: 10px; + border-bottom-color: transparent; + } +} + +.stat-count { + margin-right: 6px; + font-size: 16px; + font-weight: bold; +} + +.stat-action { + font-size: 13px; +} + +.operation { + border-bottom: 1px solid $color-border; +} + +.operation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background: #fafafa; +} + +.operation-content { + padding: 5px 10px 10px; +} + +.operation-action { + font-size: 12px; + color: $color-grey; +} + +.operation-key { + @extend %translationKeyBase; + font-size: 11px; +} + +.operation-textLabel { + margin-right: 5px; + font-size: 12px; + font-weight: bold; +} + +.operation-text { + display: inline-block; + font-size: 12px; +} + +.noChanges { + padding: 20px 10px; + font-style: italic; + font-size: 12px; + color: $color-grey; +} diff --git a/webapp/app/pods/components/operations-peek/item/template.hbs b/webapp/app/pods/components/operations-peek/item/template.hbs new file mode 100644 index 00000000..64c2afd0 --- /dev/null +++ b/webapp/app/pods/components/operations-peek/item/template.hbs @@ -0,0 +1,51 @@ +
    +
    + {{revisionOperation.language.name}} + +
    + + + +
    +
    + + {{#if showStats}} +
      + {{#each revisionOperation.stats as |stat|}} +
    • + {{stat.count}}{{stat.action}} +
    • + {{else}} +
    • No changes on this language
    • + {{/each}} +
    + {{/if}} + + {{#if showOperations}} +
      + {{#each revisionOperation.operations as |operation|}} +
    • +
      + {{operation.key}} + {{operation.action}} +
      + +
      + {{#if operation.previousText}} + {{#if operation.text}} +
      Previous:
      {{operation.previousText}}
      +
      Text:
      {{operation.text}}
      + {{else}} +
      {{operation.previousText}}
      + {{/if}} + {{else}} +
      {{operation.text}}
      + {{/if}} +
      +
    • + {{else}} +
    • No changes on this language
    • + {{/each}} +
    + {{/if}} +
    diff --git a/webapp/app/pods/components/operations-peek/template.hbs b/webapp/app/pods/components/operations-peek/template.hbs new file mode 100644 index 00000000..fa3772a9 --- /dev/null +++ b/webapp/app/pods/components/operations-peek/template.hbs @@ -0,0 +1,5 @@ +
    + {{#each revisionOperations as |revisionOperation|}} + {{operations-peek/item revisionOperation=revisionOperation}} + {{/each}} +
    diff --git a/webapp/app/pods/components/page-title/component.js b/webapp/app/pods/components/page-title/component.js new file mode 100644 index 00000000..01a0edcc --- /dev/null +++ b/webapp/app/pods/components/page-title/component.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +// Attributes: +// text: String +export default Component.extend({ + tagName: 'h1' +}); diff --git a/webapp/app/pods/components/page-title/styles.scss b/webapp/app/pods/components/page-title/styles.scss new file mode 100644 index 00000000..05b3e496 --- /dev/null +++ b/webapp/app/pods/components/page-title/styles.scss @@ -0,0 +1,21 @@ +& { + display: flex; + align-items: center; + width: 100%; + padding-bottom: 10px; + border-bottom: 1px solid rgba($color-black, 0.1); + color: $color-black; +} + +.icon { + height: 27px; + width: 27px; + margin-right: 6px; + fill: rgba($color-black, 0.3); +} + +.title { + display: block; + font-size: 22px; + font-weight: 900; +} diff --git a/webapp/app/pods/components/page-title/template.hbs b/webapp/app/pods/components/page-title/template.hbs new file mode 100644 index 00000000..b86d78e3 --- /dev/null +++ b/webapp/app/pods/components/page-title/template.hbs @@ -0,0 +1,5 @@ +{{#if icon}} + {{inline-svg icon class='icon'}} +{{/if}} + +{{text}} diff --git a/webapp/app/pods/components/phoenix-channel-listener/component.js b/webapp/app/pods/components/phoenix-channel-listener/component.js new file mode 100644 index 00000000..8cfa5627 --- /dev/null +++ b/webapp/app/pods/components/phoenix-channel-listener/component.js @@ -0,0 +1,27 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +export default Component.extend({ + session: service(), + phoenix: service(), + + init() { + this._super(...arguments); + + if (!this.session.credentials.isAuthenticated || !this.project) return; + + const phoenixService = this.phoenix; + const token = `Bearer ${this.session.credentials.token}`; + + phoenixService + .getChannel(`projects:${this.project.id}`, {token}) + .then(phoenixService.joinChannel) + .then(channel => phoenixService.bindChannelEvents(channel, this.session.credentials.user.id)) + .then(channel => this.set('channel', channel)); + }, + + willDestroyElement() { + this._super(...arguments); + this.phoenix.leaveChannel(this.channel); + } +}); diff --git a/webapp/app/pods/components/project-activities-filter/component.js b/webapp/app/pods/components/project-activities-filter/component.js new file mode 100644 index 00000000..9a87f950 --- /dev/null +++ b/webapp/app/pods/components/project-activities-filter/component.js @@ -0,0 +1,86 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +const ACTIONS_PREFIX = 'components.project_activities_filter.actions.'; + +// Attributes: +// collaborators: Array of +// batchFilter: Boolean +// actionFilter: String +// batchFilterChange: Function +// actionFilterChange: Function +// userFilterChange: Function +export default Component.extend({ + i18n: service(), + + keys: [ + 'new', + 'renew', + 'remove', + 'update', + 'sync', + 'merge', + 'rollback', + 'correct_conflict', + 'uncorrect_conflict', + 'correct_all', + 'uncorrect_all', + 'conflict_on_proposed', + 'conflict_on_corrected', + 'conflict_on_slave', + 'document_delete' + ], + + mappedActions: computed('keys', function() { + const actions = this.keys.map(key => { + return { + value: key, + label: `${ACTIONS_PREFIX}${key}` + }; + }); + + actions.unshift({ + label: 'components.project_activities_filter.actions_default_option_text', + value: null + }); + + return actions; + }), + + mappedUsers: computed('collaborators.[]', function() { + const users = this.collaborators + .filter(collaborator => !collaborator.isPending) + .map(({user: {fullname, id}}) => ({label: fullname, value: id})); + + users.unshift({ + label: this.i18n.t('components.project_activities_filter.collaborators_default_option_text'), + value: null + }); + + return users; + }), + + actionFilterValue: computed('actionFilter', 'mappedActions.[]', function() { + return this.mappedActions.find(({value}) => value === this.actionFilter); + }), + + userFilterValue: computed('userFilter', 'mappedUsers.[]', function() { + return this.mappedUsers.find(({value}) => value === this.userFilter); + }), + + actions: { + batchFilterChange(event) { + this.batchFilterChange(!!event.target.checked); + }, + + actionFilterChange(action) { + this.batchFilterChange(false); + this.actionFilterChange(action); + }, + + userFilterChange(user) { + this.userFilterChange(user); + } + } +}); diff --git a/webapp/app/pods/components/project-activities-filter/styles.scss b/webapp/app/pods/components/project-activities-filter/styles.scss new file mode 100644 index 00000000..a871f7b8 --- /dev/null +++ b/webapp/app/pods/components/project-activities-filter/styles.scss @@ -0,0 +1,16 @@ +.filterList { + display: flex; + align-items: center; + justify-content: flex-end; + position: relative; +} + +.filterList-item { + min-width: 150px; + margin-left: 20px; + font-size: 12px; +} + +.label-text { + color: $color-grey; +} diff --git a/webapp/app/pods/components/project-activities-filter/template.hbs b/webapp/app/pods/components/project-activities-filter/template.hbs new file mode 100644 index 00000000..21c8a1f7 --- /dev/null +++ b/webapp/app/pods/components/project-activities-filter/template.hbs @@ -0,0 +1,41 @@ +
    +
    +
      + {{#unless actionFilter}} +
    • + +
    • + {{/unless}} + +
    • + {{#power-select + searchEnabled=false + selected=actionFilterValue + options=mappedActions + onchange=(action "actionFilterChange" value='value') as |action| + }} + {{t action.label}} + {{/power-select}} +
    • + +
    • + {{#power-select + searchEnabled=false + selected=userFilterValue + options=mappedUsers + onchange=(action "userFilterChange" value='value') as |user| + }} + {{user.label}} + {{/power-select}} +
    • +
    +
    +
    diff --git a/webapp/app/pods/components/project-activities-list/component.js b/webapp/app/pods/components/project-activities-list/component.js new file mode 100644 index 00000000..fd4e3046 --- /dev/null +++ b/webapp/app/pods/components/project-activities-list/component.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// activities: Array of object +export default Component.extend(); diff --git a/webapp/app/pods/components/project-activities-list/styles.scss b/webapp/app/pods/components/project-activities-list/styles.scss new file mode 100644 index 00000000..27102eed --- /dev/null +++ b/webapp/app/pods/components/project-activities-list/styles.scss @@ -0,0 +1,15 @@ +.list { + position: relative; + + &:before { + display: block; + position: absolute; + content: ''; + width: 1px; + height: 100%; + top: 0; + left: 9px; + z-index: 8; + background: #eee; + } +} diff --git a/webapp/app/pods/components/project-activities-list/template.hbs b/webapp/app/pods/components/project-activities-list/template.hbs new file mode 100644 index 00000000..d59e1937 --- /dev/null +++ b/webapp/app/pods/components/project-activities-list/template.hbs @@ -0,0 +1,19 @@ +{{#if activities}} +
      + {{#each activities key='id' as |activity|}} + {{activity-item + compact=compact + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='project_activities_list_item' + activity=activity + project=project + }} + {{/each}} +
    +{{else}} + {{empty-content + iconPath='assets/empty.svg' + text=(t 'components.project_activities_list.empty_activities_text') + }} +{{/if}} diff --git a/webapp/app/pods/components/project-activity/component.js b/webapp/app/pods/components/project-activity/component.js new file mode 100644 index 00000000..7aab236f --- /dev/null +++ b/webapp/app/pods/components/project-activity/component.js @@ -0,0 +1,135 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {not, reads, equal} from '@ember/object/computed'; +import Component from '@ember/component'; +import {underscore} from '@ember/string'; + +import activityActivitiesQuery from 'accent-webapp/queries/activity-activities'; +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +const ROLLBACKABLE_ACTIONS = [ + 'sync', + 'merge', + 'document_delete', + 'uncorrect_all', + 'correct_all', + 'update', + 'correct_conflict', + 'uncorrect_conflict', + 'conflict_on_slave', + 'conflict_on_corrected', + 'conflict_on_proposed', + 'merge_on_proposed', + 'merge_on_corrected' +]; + +// componentTranslationPrefix: String +export default Component.extend({ + i18n: service(), + apollo: service(), + + isRollbacking: false, + canRollback: not('project.isFileOperationsLocked'), + showStats: reads('activity.stats'), + isRollbacked: reads('activity.isRollbacked'), + isEmptyType: equal('activity.valueType', 'EMPTY'), + previousTranslationIsEmptyType: equal('activity.previousTranslation.valueType', 'EMPTY'), + operationsLoading: false, + + translationKey: parsedKeyProperty('activity.translation.key'), + + init() { + this._super(...arguments); + + if (this.activity.isBatch && this.activity.action !== 'rollback') { + this._fetchActivities(1); + } + }, + + localizedStats: computed('activity.stats.[]', function() { + return this.activity.stats.map(stat => { + const text = this.i18n.t(`components.project_activity.stats_text.${underscore(stat.action)}`); + const count = stat.count; + + return {text, count}; + }); + }), + + statsLabel: computed(function() { + return this.i18n.t('components.project_activity.stats_label_text'); + }), + + actionExplanation: computed('activity.action', function() { + if (!this.activity.action) return; + + return this.i18n.t(`components.project_activity.action_explanation.${this.activity.action}`); + }), + + actionText: computed('activity.action', function() { + if (!this.activity.action) return; + + return this.i18n.t(`components.project_activity.action_text.${this.activity.action}`); + }), + + showTextDifferences: computed('activity.{text,previousTranslation.text}', function() { + return ( + this.activity.previousTranslation && + this.activity.previousTranslation.text && + this.activity.text !== this.activity.previousTranslation.text && + this.activity.text !== null + ); + }), + + showPreviousTranslationText: computed('activity.previousTranslation.{text,valueType}', function() { + return this.activity.previousTranslation.text || this.activity.previousTranslation.valueType === 'EMPTY'; + }), + + showLastSyncedText: computed('activity.previousTranslation.{text,proposedText}', function() { + return this.activity.previousTranslation.proposedText !== this.activity.previousTranslation.text; + }), + + isRollbackable: computed('isRollbacked', 'activity.action', 'canRollback', function() { + if (!this.onRollback) return false; + if (!this.canRollback) return false; + if (this.isRollbacked) return false; + + return ROLLBACKABLE_ACTIONS.indexOf(this.activity.action) !== -1; + }), + + actions: { + refreshActivities(page) { + this._fetchActivities(page); + }, + + rollback() { + /* eslint-disable no-alert */ + if (!window.confirm(this.i18n.t('components.project_activity.rollback_confirm'))) return; + /* eslint-enable no-alert */ + + this.set('isRollbacking', true); + this.onRollback().then(() => this.set('isRollbacking', false)); + } + }, + + _fetchActivities(page) { + this.set('operationsLoading', true); + + const variables = { + projectId: this.project.id, + activityId: this.activity.id, + page + }; + + this.apollo.client + .query({ + query: activityActivitiesQuery, + variables + }) + .then(({data}) => { + const operations = data.viewer.project.activity.operations; + + this.set('operationsLoading', false); + this.set('operations', operations); + }); + } +}); diff --git a/webapp/app/pods/components/project-activity/styles.scss b/webapp/app/pods/components/project-activity/styles.scss new file mode 100644 index 00000000..e4bae120 --- /dev/null +++ b/webapp/app/pods/components/project-activity/styles.scss @@ -0,0 +1,219 @@ +.activity-title { + font-size: 19px; + color: #666; +} + +.activity-title-author { + font-weight: bold; + color: #333; +} + +.activity-explanation { + margin: 20px 0 0; + font-weight: 300; + font-size: 14px; + color: #555; + padding: 10px 12px; + background: #fafafa; + border-radius: 3px; +} + +.activity-explanation-label { + display: block; + margin-bottom: 4px; + font-size: 12px; + font-weight: 500; + color: #111; +} + +.activity-date { + display: block; + font-size: 12px; + font-style: italic; + color: #aaa; +} + +.activity-meta { + display: flex; + align-items: baseline; +} + +.rollbackButton { + font-size: 12px; + margin-left: 10px; + color: lighten($color-error, 25%); + + &:focus, + &:hover { + color: $color-error; + } + + .label { + padding: 0; + } + + .loading { + top: -9px; + } +} + +.details { + display: flex; +} + +.details-states { + flex: 1 1 auto; + width: 45%; +} + +.details-associations { + flex: 1 1 auto; + width: 55%; +} + +.details-label { + display: block; + margin-bottom: 15px; + font-size: 15px; + color: $color-primary; +} + +.details-associations-overflow { + max-height: 400px; + overflow-y: scroll; + border-radius: 3px; + padding: 5px 7px; + border: 1px solid #eee; +} + +.stats, +.details-associations, +.translation-state { + margin-top: 20px; +} + +.translation-state-items, +.stats-items { + margin-right: 20px; + border: 1px solid #eee; + padding: 10px; + border-radius: 3px; + background: #fff; +} + +.translation-state-item, +.stats-item { + padding-bottom: 7px; + margin-bottom: 7px; + border-bottom: 1px solid #eee; + font-size: 14px; + + &:last-of-type { + border-bottom: 0; + padding-bottom: 0; + margin-bottom: 0; + } +} + +.translation-state-item { + margin-bottom: 5px; +} + +.translation-state-label { + color: #444; +} + +.translation-state-key { + display: block; + @extend %translationKeyBase; + padding: 5px; + text-decoration: none; + font-weight: 600; + font-size: 12px; + + &:focus, + &:hover { + color: $color-primary; + text-decoration: underline; + } +} + +.translation-state-key-prefix { + display: block; + font-size: 11px; + color: #959595; + font-weight: 300; +} + +.translation-state-document { + display: block; + padding: 5px; + font-size: 12px; + color: #333; +} + +.translation-state-reviewed { + display: block; + padding: 5px; + font-size: 12px; +} + +.translation-state-value { + display: block; + padding: 5px; + white-space: pre-wrap; + font-size: 13px; + color: #adadad; +} + +.translation-state-value--empty { + font-style: italic; + font-size: 12px; +} + +.rollbackedBadge { + display: inline-block; + margin-left: 7px; + font-size: 12px; + font-style: italic; + color: $color-error; +} + +.translation-state-label { + font-size: 12px; +} + +.translation-state-value { + display: block; + font-weight: 400; +} + +.textDiff { + white-space: pre-wrap; + + .added { + background: lighten($color-success, 42%); + color: $color-success; + } + + .removed { + background: lighten($color-error, 48%); + color: $color-error; + } +} + +@media (max-width: ($screen-md)) { + .details { + flex-direction: column; + } + + .translation-state-items, + .stats-items { + margin-right: 0; + } + + .details-states, + .details-associations { + width: 100%; + } +} diff --git a/webapp/app/pods/components/project-activity/template.hbs b/webapp/app/pods/components/project-activity/template.hbs new file mode 100644 index 00000000..99ce971d --- /dev/null +++ b/webapp/app/pods/components/project-activity/template.hbs @@ -0,0 +1,227 @@ +
    +

    + + {{activity.user.fullname}} + + + {{actionText}} +

    + +
    + {{time-ago-in-words-tag date=activity.insertedAt class='activity-date'}} + + {{#if isRollbacked}} +
    + {{t 'components.project_activity.rollbacked_label'}} +
    + {{else if (get permissions 'rollback')}} + {{#if isRollbackable}} + {{#async-button + onClick=(action 'rollback') + loading=isRollbacking + class='button button--red rollbackButton' + }} + {{t 'components.project_activity.rollback'}} + {{/async-button}} + {{/if}} + {{/if}} +
    + +

    + + {{t 'components.project_activity.explanation_label'}} + + + {{actionExplanation}} +

    +
    + +
    +
    + {{#if showStats}} +
    + + {{t 'components.project_activity.stats_label'}} + + +
    + {{#each localizedStats as |stat|}} +
    + {{stat.text}}: {{stat.count}} +
    + {{/each}} +
    +
    + {{/if}} + + {{#if activity.previousTranslation}} +
    + + {{t 'components.project_activity.details_label'}} + + +
    + {{#if activity.translation.key}} +
    + + Key: + + + {{#link-to + 'logged-in.project.translation' + project.id + activity.translation.id + class='translation-state-key' + }} + {{translationKey.prefix}} + {{translationKey.value}} + {{/link-to}} +
    + {{/if}} + + {{#if activity.document}} +
    + + {{t 'components.project_activity.file_label'}} + + + {{activity.document.path}} ({{activity.document.format}}) +
    + {{/if}} + +
    + + {{t 'components.project_activity.review_label'}} + + + + {{#if activity.previousTranslation.isReviewed}} + {{t 'components.project_activity.reviewed_yes'}} + {{else}} + {{t 'components.project_activity.reviewed_no'}} + {{/if}} + +
    + + {{#if showLastSyncedText}} +
    + + {{t 'components.project_activity.last_synced_text_label'}} + + + {{#if previousTranslationIsEmptyType}} + {{t 'components.project_activity.empty_value'}} + {{else}} + {{activity.previousTranslation.proposedText}} + {{/if}} +
    + {{/if}} + + {{#if showPreviousTranslationText}} +
    + + {{t 'components.project_activity.text_before_action_label'}} + + + {{#if previousTranslationIsEmptyType}} + {{t 'components.project_activity.empty_value'}} + {{else}} + {{activity.previousTranslation.text}} + {{/if}} +
    + {{/if}} + +
    + + {{t 'components.project_activity.new_text_label'}} + + + + {{#if isEmptyType}} + {{t 'components.project_activity.empty_value'}} + {{else}} + {{activity.text}} + {{/if}} +
    + + {{#if showTextDifferences}} +
    + + {{t 'components.project_activity.text_differences_label'}} + + +
    {{string-diff activity.text activity.previousTranslation.text}}
    +
    + {{/if}} +
    +
    + {{/if}} +
    + +
    + {{#if activity.rollbackedOperation}} + + {{t 'components.project_activity.rollbacked_operation_label'}} + + {{activity-item + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='project_activities_list_item' + project=project + activity=activity.rollbackedOperation + }} + {{/if}} + + {{#if activity.rollbackOperation}} + + {{t 'components.project_activity.rollback_operation_label'}} + + {{activity-item + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='translation_activities_list_item' + project=project + activity=activity.rollbackOperation + }} + {{/if}} + + {{#if activity.batchOperation}} + + {{t 'components.project_activity.batch_operation_label'}} + + {{activity-item + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='project_activities_list_item' + project=project + activity=activity.batchOperation + }} + {{/if}} + + {{#if operationsLoading}} + {{loading-content label=(t 'pods.project.activities.show.loading_activities')}} + {{else if operations}} + + {{t 'components.project_activity.operations_label'}} + + +
    + {{#each operations.entries key='id' as |activity|}} + {{activity-item + compact=true + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='project_activities_list_item' + project=project + activity=activity + }} + {{/each}} +
    + + {{resource-pagination + meta=operations.meta + onSelectPage=(action 'refreshActivities') + }} + {{/if}} +
    +
    diff --git a/webapp/app/pods/components/project-comments-list/component.js b/webapp/app/pods/components/project-comments-list/component.js new file mode 100644 index 00000000..62964487 --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/component.js @@ -0,0 +1,35 @@ +import {computed} from '@ember/object'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// comments: Array of +export default Component.extend({ + tagName: 'ul', + + translationsById: computed('comments', function() { + return this.comments.map(comment => comment.translation).reduce((memo, translation) => { + if (!memo[translation.id]) memo[translation.id] = translation; + + return memo; + }, {}); + }), + + commentsByTranslationId: computed('comments', function() { + return this.comments.reduce((memo, comment) => { + memo[comment.translation.id] = memo[comment.translation.id] || []; + memo[comment.translation.id].push(comment); + + return memo; + }, {}); + }), + + commentsByTranslation: computed('commentsByTranslationId', 'translationsById', function() { + return Object.keys(this.commentsByTranslationId).map(translationId => { + return { + items: this.commentsByTranslationId[translationId], + value: this.translationsById[translationId] + }; + }); + }) +}); diff --git a/webapp/app/pods/components/project-comments-list/item/component.js b/webapp/app/pods/components/project-comments-list/item/component.js new file mode 100644 index 00000000..92b04949 --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/item/component.js @@ -0,0 +1,11 @@ +import Component from '@ember/component'; + +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +// Attributes: +// groupedComment: Object +export default Component.extend({ + tagName: 'li', + + translationKey: parsedKeyProperty('groupedComment.value.key') +}); diff --git a/webapp/app/pods/components/project-comments-list/item/styles.scss b/webapp/app/pods/components/project-comments-list/item/styles.scss new file mode 100644 index 00000000..24206311 --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/item/styles.scss @@ -0,0 +1,53 @@ +& { + margin-bottom: 45px; +} + +.item-link { + transition: $transition-speed $transition-easing; + transition-property: color; + @extend %translationKeyBase; + display: inline-block; + color: $color-primary; + font-size: 12px; + font-weight: bold; + text-decoration: none; + + &:focus, + &:hover { + color: darken($color-primary, 15%); + } +} + +.item-header { + margin-bottom: 15px; +} + +.item-key-prefix { + display: block; + margin: 6px 0 0; + font-size: 11px; + color: #959595; + font-weight: 300; +} + +.item-language { + transition: $transition-speed $transition-easing; + transition-property: color; + display: block; + font-size: 14px; + text-decoration: none; + color: $color-black; +} + +.item-badge { + font-size: 12px; + color: $color-primary; + text-decoration: none; +} + +.removedBadge { + display: block; + margin-top: 5px; + font-size: 12px; + color: $color-error; +} diff --git a/webapp/app/pods/components/project-comments-list/item/template.hbs b/webapp/app/pods/components/project-comments-list/item/template.hbs new file mode 100644 index 00000000..5559ec10 --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/item/template.hbs @@ -0,0 +1,31 @@ +
    + {{#link-to + 'logged-in.project.revision.translations' + project.id + groupedComment.value.revision.id + class='item-language' + }} + {{groupedComment.value.revision.language.name}} + {{/link-to}} + + {{#link-to + 'logged-in.project.translation.comments' + project.id + groupedComment.value.id + class='item-link' + }} + {{translationKey.prefix}} + {{translationKey.value}} + {{/link-to}} + + {{#if groupedComment.value.removed}} +
    + {{t 'components.translation_splash_title.removed_label' removedAt=(time-ago-in-words groupedComment.value.updatedAt)}} +
    + {{/if}} +
    + +{{translation-comments-list + comments=groupedComment.items + class=(if groupedComment.value.removed 'translationRemoved') +}} diff --git a/webapp/app/pods/components/project-comments-list/styles.scss b/webapp/app/pods/components/project-comments-list/styles.scss new file mode 100644 index 00000000..d95a525f --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/styles.scss @@ -0,0 +1,4 @@ +& { + display: block; + margin-top: 25px; +} diff --git a/webapp/app/pods/components/project-comments-list/template.hbs b/webapp/app/pods/components/project-comments-list/template.hbs new file mode 100644 index 00000000..2124fc08 --- /dev/null +++ b/webapp/app/pods/components/project-comments-list/template.hbs @@ -0,0 +1,5 @@ +{{#each commentsByTranslation as |groupedComment|}} + {{project-comments-list/item project=project groupedComment=groupedComment}} +{{else}} + {{empty-content iconPath='assets/bubble.svg' text=(t 'components.project_comments_list.no_comments')}} +{{/each}} diff --git a/webapp/app/pods/components/project-create-form/component.js b/webapp/app/pods/components/project-create-form/component.js new file mode 100644 index 00000000..52ed073a --- /dev/null +++ b/webapp/app/pods/components/project-create-form/component.js @@ -0,0 +1,69 @@ +import {computed} from '@ember/object'; +import {scheduleOnce} from '@ember/runloop'; +import {inject as service} from '@ember/service'; +import {reads, not} from '@ember/object/computed'; +import {htmlSafe} from '@ember/string'; +import Component from '@ember/component'; + +// Attributes: +// languages: Array of +// error: Boolean +// onCreate: Function +export default Component.extend({ + languageSearcher: service('language-searcher'), + + name: null, + languagesCopy: reads('languages'), + emptyLanguage: not('language'), + isCreating: false, + + language: computed('mappedLanguages.[]', function() { + const first = this.mappedLanguages[0]; + + return first ? first.value : null; + }), + + languageValue: computed('language', 'mappedLanguages.[]', function() { + return this.mappedLanguages.find(({value}) => value === this.language); + }), + + mappedLanguages: computed('languagesCopy.[]', function() { + if (!this.languagesCopy) return []; + + return this._mapLanguages(this.languagesCopy); + }), + + didInsertElement() { + scheduleOnce('afterRender', this, function() { + this.element.querySelector('.textInput').focus(); + }); + }, + + actions: { + submit() { + this.set('isCreating', true); + const languageId = this.language; + const name = this.name; + + this.onCreate({languageId, name}).then(() => { + if (!this.isDestroyed) this.set('isCreating', false); + }); + }, + + searchLanguages(term) { + return this.languageSearcher.search({term}).then(languages => { + this.set('languagesCopy', languages); + + return this._mapLanguages(languages); + }); + } + }, + + _mapLanguages(languages) { + return languages.map(({id, name, slug}) => { + const label = htmlSafe(`${name} ${slug}`); + + return {label, value: id}; + }); + } +}); diff --git a/webapp/app/pods/components/project-create-form/styles.scss b/webapp/app/pods/components/project-create-form/styles.scss new file mode 100644 index 00000000..e4f3ec63 --- /dev/null +++ b/webapp/app/pods/components/project-create-form/styles.scss @@ -0,0 +1,60 @@ +& { + padding: 20px; + background: #fff; +} + +.title { + margin-bottom: 20px; + text-align: center; + font-size: 27px; + font-weight: 300; + color: $color-primary; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 10px; + margin-right: 25px; + min-width: 250px; + width: 100%; + font-size: 12px; + font-family: $font-primary; +} + +.errors { + margin-bottom: 15px; + padding-bottom: 5px; + border-bottom: 1px solid rgba($color-error, 0.3); +} + +.error { + margin-bottom: 5px; + color: $color-error; + font-size: 13px; + font-weight: bold; +} + +.formItem { + margin-bottom: 20px; +} + +.formItem-label { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.formActions { + padding-top: 20px; + border-top: 1px solid #eee; +} + +.ember-power-select-trigger { + min-height: 31px; + border-color: #eee; + border: 1px solid #eee; + background: #fafafa; + box-shadow: 0 1px 5px rgba(#000, 0.06); +} diff --git a/webapp/app/pods/components/project-create-form/template.hbs b/webapp/app/pods/components/project-create-form/template.hbs new file mode 100644 index 00000000..eb5b4294 --- /dev/null +++ b/webapp/app/pods/components/project-create-form/template.hbs @@ -0,0 +1,57 @@ +

    + {{t 'components.project_create_form.title'}} +

    + +{{#if error}} +
    +
    + {{t 'components.project_create_form.error'}} +
    +
    +{{/if}} + +
    + + + {{input + value=name + class='textInput' + }} +
    + +
    + + + {{#power-select + search=(action 'searchLanguages') + options=mappedLanguages + selected=languageValue + searchPlaceholder=(t 'components.project_create_form.language_search_placeholder') + onchange=(action (mut language) value='value') as |option| + }} + {{option.label}} + {{/power-select}} +
    + +
    + {{#link-to + 'logged-in.projects' + class='button + button--filled button--white' + }} + {{t 'components.project_create_form.cancel_button'}} + {{/link-to}} + + {{#async-button + disabled=emptyLanguage + class='button button--filled' + loading=isCreating + onClick=(action 'submit') + }} + {{t 'components.project_create_form.save_button'}} + {{/async-button}} +
    diff --git a/webapp/app/pods/components/project-file-operation/styles.scss b/webapp/app/pods/components/project-file-operation/styles.scss new file mode 100644 index 00000000..9f28fa7c --- /dev/null +++ b/webapp/app/pods/components/project-file-operation/styles.scss @@ -0,0 +1,144 @@ +& { + position: relative; + background: #fff; +} + +.closeButton { + position: absolute; + top: 10px; + right: 10px; + padding: 0; + background: transparent; + + &:focus, + &:hover { + .closeButton-icon { + fill: $color-error; + } + } +} + +.closeButton-content { + display: flex; +} + +.closeButton-icon { + width: 25px; + height: 25px; + fill: #777; +} + +.sectionType { + display: flex; + align-items: center; + margin-bottom: 5px; + font-size: 14px; + font-weight: 300; + color: $color-primary; +} + +.sectionType-icon { + width: 18px; + height: 18px; + margin-right: 4px; + fill: $color-primary; +} + +.versionTitle { + display: flex; + align-items: center; +} + +.versionTitle-name { + display: inline-block; + font-size: 23px; + color: $color-primary; + font-weight: normal; +} + +.versionTitle-tag { + display: inline-flex; + align-items: center; + margin-left: 10px; + font-family: $font-monospace; + font-size: 14px; + font-weight: normal; + color: #888; +} + +.versionTitle-tag-icon { + width: 22px; + height: 22px; + fill: #bbb; +} + +.title { + padding: 12px 20px; + border-bottom: 1px solid #eee; + background: #f7f7f7; + font-size: 19px; + font-weight: bold; +} + +.subtitle { + font-size: 13px; + font-weight: bold; +} + +.subtitle-label { + font-size: 11px; + font-weight: normal; + color: #333; +} + +.render { + position: relative; + padding: 15px; + min-height: 50px; + border: 1px solid lighten($color-grey, 20%); + border-top: 0; + box-shadow: inset 0 2px 6px rgba($color-black, 0.05); + background: $color-white; + overflow-x: scroll; + font-family: $font-monospace; + font-size: 11px; + line-height: 1.7; +} + +.render-export { + position: absolute; + top: 60px; + right: 10px; +} + +.sections { + display: flex; +} + +.sections-file, +.sections-preview { + flex: 1 1 50%; + padding: 20px; +} + +.sections-preview-title { + font-size: 13px; + color: $color-grey; +} + +.sections-preview-empty { + padding: 30px 10px; + margin: 10px 0 0; + background: #fafafa; + border: 1px solid #eee; + text-align: center; + font-size: 13px; + font-style: italic; + color: $color-grey; +} + +@media (max-width: (800px)) { + .sections { + flex-direction: column; + } +} diff --git a/webapp/app/pods/components/project-navigation/component.js b/webapp/app/pods/components/project-navigation/component.js new file mode 100644 index 00000000..a32eb0f0 --- /dev/null +++ b/webapp/app/pods/components/project-navigation/component.js @@ -0,0 +1,34 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +export default Component.extend({ + globalState: service('global-state'), + + isListShowing: reads('globalState.isProjectNavigationListShowing'), + + selectedRevision: computed('globalState.revision', 'revisions.[]', function() { + const selected = this.globalState.revision; + + if (selected && this.revisions.map(({id}) => id).includes(selected)) { + return selected; + } + + if (!this.revisions) return; + return this.revisions[0].id; + }), + + actions: { + toggleMenu() { + this.set('globalState.isProjectNavigationListShowing', !this.globalState.isProjectNavigationListShowing); + }, + + closeModal() { + this.set('globalState.isProjectNavigationListShowing', !this.globalState.isProjectNavigationListShowing); + } + } +}); diff --git a/webapp/app/pods/components/project-navigation/list/component.js b/webapp/app/pods/components/project-navigation/list/component.js new file mode 100644 index 00000000..cbf1b170 --- /dev/null +++ b/webapp/app/pods/components/project-navigation/list/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'ul' +}); diff --git a/webapp/app/pods/components/project-navigation/list/styles.scss b/webapp/app/pods/components/project-navigation/list/styles.scss new file mode 100644 index 00000000..5efb3f8f --- /dev/null +++ b/webapp/app/pods/components/project-navigation/list/styles.scss @@ -0,0 +1,92 @@ +& { + display: flex; + flex-direction: column; + padding: 10px; +} + +.list-item { + &:last-of-type { + .list-item-link { + margin-bottom: 0; + } + } +} + +.list-item-link { + display: flex; + align-items: center; + position: relative; + left: 1px; + transition: $transition-speed $transition-easing; + transition-property: color, background; + padding: 8px 12px; + margin-bottom: 10px; + text-decoration: none; + font-weight: 600; + font-size: 13px; + border-radius: 3px; + + &:hover, + &:focus, + &.active { + background: #f3f3f3; + } + + &.active { + .list-item-link-icon { + transform: scale(1); + fill: $color-primary; + } + + .list-item-link-text { + color: $color-primary; + } + } +} + +.list-item-link-text { + width: 127px; + padding: 0 0 0 12px; + color: rgba($color-black, 0.4); +} + +.list-item-link-icon { + transition: $transition-speed $transition-easing; + transition-property: fill; + display: inline-block; + width: 19px; + height: 19px; + fill: rgba($color-black, 0.3); +} + +@media (max-width: 800px) { + .list-item-link-text { + display: none; + } +} + +@media (max-width: ($screen-sm)) { + & { + background: #fff; + } + + .list-item-link { + left: 0; + border-radius: 0; + font-size: 14px; + padding-left: 8px; + } + + .list-item-link-text { + display: block; + position: static; + padding: 0; + opacity: 1; + text-align: left; + width: auto; + } + + .list-item-link-icon { + margin-right: 6px; + } +} diff --git a/webapp/app/pods/components/project-navigation/list/template.hbs b/webapp/app/pods/components/project-navigation/list/template.hbs new file mode 100644 index 00000000..c11172fc --- /dev/null +++ b/webapp/app/pods/components/project-navigation/list/template.hbs @@ -0,0 +1,101 @@ +
  • + {{#link-to + 'logged-in.project.index' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/home.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.dashboard_link_title'}} + {{/link-to}} +
  • + +{{#if (get permissions 'index_translations')}} +
  • + {{#link-to + 'logged-in.project.revision.translations' + project.id + selectedRevision + class='list-item-link' + }} + {{inline-svg '/assets/search.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.translations_link_title'}} + {{/link-to}} +
  • +{{/if}} + +{{#if (get permissions 'index_translations')}} +
  • + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + selectedRevision + class='list-item-link' + }} + {{inline-svg '/assets/check.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.conflicts_link_title'}} + {{/link-to}} +
  • +{{/if}} + +{{#if (get permissions 'index_comments')}} +
  • + {{#link-to + 'logged-in.project.comments' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/bubble.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.conversation_link_title'}} + {{/link-to}} +
  • +{{/if}} + +{{#if (get permissions 'index_project_activities')}} +
  • + {{#link-to + 'logged-in.project.activities' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/activity.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.activities_link_title'}} + {{/link-to}} +
  • +{{/if}} + +{{#if (get permissions 'index_documents')}} +
  • + {{#link-to + 'logged-in.project.files' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/file.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.sync_link_title'}} + {{/link-to}} +
  • +{{/if}} + +{{#if (get permissions 'index_versions')}} +
  • + {{#link-to + 'logged-in.project.versions' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/tag.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.versions_link_title'}} + {{/link-to}} +
  • +{{/if}} + +
  • + {{#link-to + 'logged-in.project.edit' + project.id + class='list-item-link' + }} + {{inline-svg '/assets/gear.svg' class='list-item-link-icon'}} + {{t 'components.project_navigation.settings_link_title'}} + {{/link-to}} +
  • diff --git a/webapp/app/pods/components/project-navigation/styles.scss b/webapp/app/pods/components/project-navigation/styles.scss new file mode 100644 index 00000000..ee2342f2 --- /dev/null +++ b/webapp/app/pods/components/project-navigation/styles.scss @@ -0,0 +1,60 @@ +& { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + padding-top: 40px; +} + +.nameLink { + display: block; + margin: 0 0 20px; + padding: 14px 0 13px 28px; + font-size: 12px; + font-weight: 700; + text-decoration: none; + color: $color-black; + border-bottom: 1px solid #eee; +} + +.listTrigger { + display: none; + align-items: center; + width: 90px; + margin: 0 0 20px 10px; + padding: 2px 10px; + color: #aaa; + border: 1px solid #eee; + border-radius: 2px; + cursor: pointer; + font-size: 14px; +} + +.listTrigger-icon { + width: 20px; + height: 20px; + margin-right: 10px; + fill: #aaa; +} + +.modalList { + display: none; +} + +@media (max-width: ($screen-sm)) { + & { + padding-top: 10px; + } + + .list { + display: none; + } + + .modalList { + display: block; + } + + .listTrigger { + display: flex; + } +} diff --git a/webapp/app/pods/components/project-navigation/template.hbs b/webapp/app/pods/components/project-navigation/template.hbs new file mode 100644 index 00000000..81ed4765 --- /dev/null +++ b/webapp/app/pods/components/project-navigation/template.hbs @@ -0,0 +1,24 @@ + + {{inline-svg '/assets/burger.svg' class='listTrigger-icon'}} + Menu + + +
    + {{project-navigation/list + selectedRevision=selectedRevision + permissions=permissions + project=project + }} +
    + +{{#if isListShowing}} + {{#acc-modal onClose=(action 'closeModal')}} +
    + {{project-navigation/list + selectedRevision=selectedRevision + permissions=permissions + project=project + }} +
    + {{/acc-modal}} +{{/if}} diff --git a/webapp/app/pods/components/project-settings/api-token/styles.scss b/webapp/app/pods/components/project-settings/api-token/styles.scss new file mode 100644 index 00000000..4a1f3594 --- /dev/null +++ b/webapp/app/pods/components/project-settings/api-token/styles.scss @@ -0,0 +1,35 @@ +& { + margin-top: 30px; +} + +.text { + max-width: 490px; + margin: 10px 0 15px; + font-size: 13px; + font-style: italic; +} + +.token { + display: inline-block; + width: 100%; + margin: 10px 0 0; + padding: 8px; + overflow-x: scroll; + word-break: keep-all; + background: #fbfbfb; + font-family: $font-monospace; + font-size: 11px; + border: 2px solid #ddd; + border-radius: 3px; + transition-property: border-color, box-shadow; + transition: 0.3s ease-in-out; + + &:focus { + border-color: lighten($color-primary, 20%); + box-shadow: 0 0 3px 2px rgba($color-primary, 0.3); + + &::selection { + background: lighten($color-primary, 35%); + } + } +} diff --git a/webapp/app/pods/components/project-settings/api-token/template.hbs b/webapp/app/pods/components/project-settings/api-token/template.hbs new file mode 100644 index 00000000..f87fbf4c --- /dev/null +++ b/webapp/app/pods/components/project-settings/api-token/template.hbs @@ -0,0 +1,9 @@ +{{project-settings/title title=(t 'components.project_settings.api_token.title')}} + +

    + {{t 'components.project_settings.api_token.text_1'}} + + {{t 'components.project_settings.api_token.text_2'}} +

    + + diff --git a/webapp/app/pods/components/project-settings/back-link/styles.scss b/webapp/app/pods/components/project-settings/back-link/styles.scss new file mode 100644 index 00000000..86146d8c --- /dev/null +++ b/webapp/app/pods/components/project-settings/back-link/styles.scss @@ -0,0 +1,3 @@ +& { + margin-top: 20px; +} diff --git a/webapp/app/pods/components/project-settings/back-link/template.hbs b/webapp/app/pods/components/project-settings/back-link/template.hbs new file mode 100644 index 00000000..cc0b3a5c --- /dev/null +++ b/webapp/app/pods/components/project-settings/back-link/template.hbs @@ -0,0 +1,6 @@ +{{#link-to + 'logged-in.project.edit.index' + class='button button--link button--primary' +}} + {{t 'components.project_settings.back_link.title'}} +{{/link-to}} diff --git a/webapp/app/pods/components/project-settings/badges/component.js b/webapp/app/pods/components/project-settings/badges/component.js new file mode 100644 index 00000000..3c1048f0 --- /dev/null +++ b/webapp/app/pods/components/project-settings/badges/component.js @@ -0,0 +1,25 @@ +import fmt from 'npm:simple-fmt'; +import Component from '@ember/component'; +import {computed} from '@ember/object'; +import {htmlSafe} from '@ember/string'; +import config from 'accent-webapp/config/environment'; + +const {API} = config; + +export default Component.extend({ + projectUrl: computed('project.id', function() { + return fmt(API.PROJECT_PATH, this.project.id); + }), + + percentageReviewedBadgeCode: computed('percentageReviewedBadgeUrl', 'projectUrl', function() { + return htmlSafe(`![strings reviewed status](${this.percentageReviewedBadgeUrl})](${this.projectUrl})`); + }), + + percentageReviewedBadgeUrlWithDigest: computed('percentageReviewedBadgeUrl', function() { + return `${this.percentageReviewedBadgeUrl}?digest=${new Date().getTime()}`; + }), + + percentageReviewedBadgeUrl: computed('project.id', function() { + return fmt(API.PERCENTAGE_REVIEWED_BADGE_SVG_PROJECT_PATH, this.project.id); + }) +}); diff --git a/webapp/app/pods/components/project-settings/badges/styles.scss b/webapp/app/pods/components/project-settings/badges/styles.scss new file mode 100644 index 00000000..fcb29527 --- /dev/null +++ b/webapp/app/pods/components/project-settings/badges/styles.scss @@ -0,0 +1,47 @@ +& { + margin-top: 30px; +} + +.text { + max-width: 490px; + margin: 10px 0 15px; + font-size: 13px; + font-style: italic; +} + +.badge { + display: flex; + justify-content: flex-start; + align-items: center; +} + +.badge-title { + margin: 0 10px 0 0; + font-size: 13px; + color: #bbb; +} + +.badge-code { + display: inline-block; + width: 100%; + margin: 10px 0 0; + padding: 8px; + overflow-x: scroll; + word-break: keep-all; + background: #fbfbfb; + font-family: $font-monospace; + font-size: 11px; + border: 2px solid #ddd; + border-radius: 3px; + transition-property: border-color, box-shadow; + transition: 0.3s ease-in-out; + + &:focus { + border-color: lighten($color-primary, 20%); + box-shadow: 0 0 3px 2px rgba($color-primary, 0.3); + + &::selection { + background: lighten($color-primary, 35%); + } + } +} diff --git a/webapp/app/pods/components/project-settings/badges/template.hbs b/webapp/app/pods/components/project-settings/badges/template.hbs new file mode 100644 index 00000000..090e7008 --- /dev/null +++ b/webapp/app/pods/components/project-settings/badges/template.hbs @@ -0,0 +1,14 @@ +{{project-settings/title title=(t 'components.project_settings.badges.title')}} + +

    + {{t 'components.project_settings.badges.text'}} +

    + +
    + + {{t 'components.project_settings.badges.percentage_reviewed'}} + + +
    + + diff --git a/webapp/app/pods/components/project-settings/collaborators/component.js b/webapp/app/pods/components/project-settings/collaborators/component.js new file mode 100644 index 00000000..3a511957 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/component.js @@ -0,0 +1,18 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// permissions: Ember Object containing +// collaborators: Array of +// onDeleteCollaborator: Function +// onUpdateCollaborator: Function +// onCreateCollaborator: Function +export default Component.extend({ + showCreateForm: false, + + actions: { + toggleCreateForm() { + this.set('showCreateForm', !this.showCreateForm); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/collaborators/create-form/component.js b/webapp/app/pods/components/project-settings/collaborators/create-form/component.js new file mode 100644 index 00000000..6a51ce27 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/create-form/component.js @@ -0,0 +1,40 @@ +import {inject as service} from '@ember/service'; +import {computed} from '@ember/object'; +import {not, map} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// onCreate: Function +export default Component.extend({ + globalState: service('global-state'), + + isCreating: false, + email: '', + emptyEmail: not('email'), + + possibleRoles: map('globalState.roles', ({slug}) => slug), + mappedPossibleRoles: map('possibleRoles', value => ({label: `general.roles.${value}`, value})), + + roleValue: computed('role', 'mappedPossibleRoles.[]', function() { + return this.mappedPossibleRoles.find(({value}) => value === this.role); + }), + + role: computed('possibleRoles.[]', function() { + return this.possibleRoles[0]; + }), + + actions: { + selectRole(value) { + this.set('role', value); + }, + + submit() { + this.set('isCreating', true); + + this.onCreate(this.getProperties('email', 'role')) + .then(() => this.setProperties({email: ''})) + .then(() => this.set('isCreating', false)); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/collaborators/create-form/styles.scss b/webapp/app/pods/components/project-settings/collaborators/create-form/styles.scss new file mode 100644 index 00000000..8c477580 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/create-form/styles.scss @@ -0,0 +1,27 @@ +& { + display: flex; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 8px 10px; + margin-right: 10px; + min-width: 350px; + font-family: $font-primary; + font-size: 11px; +} + +.ember-power-select-trigger { + min-width: 150px; + margin-right: 10px; + border: 1px solid #eee; + background: #fff; + font-size: 12px; + box-shadow: 0 1px 5px rgba(#000, 0.06); +} + +.button { + margin-right: 5px; +} diff --git a/webapp/app/pods/components/project-settings/collaborators/create-form/template.hbs b/webapp/app/pods/components/project-settings/collaborators/create-form/template.hbs new file mode 100644 index 00000000..820c0883 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/create-form/template.hbs @@ -0,0 +1,29 @@ +{{#power-select + searchEnabled=false + selected=roleValue + options=mappedPossibleRoles + onchange=(action (mut role) value='value') as |option| +}} + {{t option.label}} +{{/power-select}} + +{{input + value=email + class='textInput' + placeholder=(t 'components.collaborator_create_form.email_placeholder') +}} + +{{#async-button + onClick=(action 'submit') + loading=isCreating + disabled=emptyEmail + class='button button--filled' +}} + {{t 'components.collaborator_create_form.create_button'}} +{{/async-button}} + +{{#if onCancel}} + +{{/if}} diff --git a/webapp/app/pods/components/project-settings/collaborators/list/component.js b/webapp/app/pods/components/project-settings/collaborators/list/component.js new file mode 100644 index 00000000..fe18f158 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/list/component.js @@ -0,0 +1,23 @@ +import {computed} from '@ember/object'; +import Component from '@ember/component'; + +// Attributes: +// collaborators: Array of +// permissions: Ember Object containing +// onDelete: Function +// onUpdate: Function +export default Component.extend({ + filteredCollaborators: computed('collaborators.[]', function() { + return this.collaborators.filter(collaborator => collaborator.isPending || !collaborator.user.isBot); + }), + + actions: { + deleteCollaborator(collaborator) { + return this.onDelete(collaborator); + }, + + updateCollaborator(collaborator, args) { + return this.onUpdate(collaborator, args); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/collaborators/list/item/component.js b/webapp/app/pods/components/project-settings/collaborators/list/item/component.js new file mode 100644 index 00000000..7e1147e5 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/list/item/component.js @@ -0,0 +1,77 @@ +import {computed} from '@ember/object'; +import {notEmpty, map, reads} from '@ember/object/computed'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// collaborator: Object +// permissions: Ember Object containing +// onDelete: Function +// onUpdate: Function +export default Component.extend({ + tagName: 'li', + classNameBindings: ['hasJoined:joined:invited'], + + session: service('session'), + i18n: service('i18n'), + globalState: service('global-state'), + + isEditing: false, + + hasJoined: notEmpty('collaborator.user.id'), + + possibleRoles: map('globalState.roles', ({slug}) => slug), + mappedPossibleRoles: map('possibleRoles', value => ({label: `general.roles.${value}`, value})), + + updatedRole: reads('collaborator.role'), + + roleValue: computed('updatedRole', 'mappedPossibleRoles.[]', function() { + return this.mappedPossibleRoles.find(({value}) => value === this.updatedRole); + }), + + canDeleteCollaborator: computed('permissions', 'session.credentials.user.id', 'collaborator.user.id', function() { + return ( + this.permissions && + this.permissions.create_collaborator && + (!this.collaborator.user || (this.collaborator.user && this.session.credentials.user.id !== this.collaborator.user.id)) + ); + }), + + canUpdateCollaborator: computed('permissions', 'session.credentials.user.id', 'collaborator.user.id', function() { + return ( + this.permissions && + this.permissions.update_collaborator && + this.collaborator.user && + this.session.credentials.user.id !== this.collaborator.user.id + ); + }), + + role: computed('collaborator.role', function() { + return this.i18n.t(`general.roles.${this.collaborator.role}`); + }), + + fullnameWithFallback: computed('hasJoined', function() { + if (this.hasJoined) { + return this.collaborator.user.fullname; + } else { + return this.collaborator.email; + } + }), + + actions: { + deleteCollaborator() { + this.onDelete(this.collaborator); + }, + + updateCollaborator() { + this.onUpdate(this.collaborator, {role: this.updatedRole}).then(() => this.set('isEditing', false)); + }, + + toggleUpdateCollaborator() { + this.setProperties({ + updatedRole: this.collaborator.role, + isEditing: !this.isEditing + }); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/collaborators/list/item/styles.scss b/webapp/app/pods/components/project-settings/collaborators/list/item/styles.scss new file mode 100644 index 00000000..fab2f1b3 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/list/item/styles.scss @@ -0,0 +1,71 @@ +& { + transition: $transition-speed $transition-easing; + transition-property: background; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + padding: 10px 15px 10px 10px; + border-bottom: 1px solid #eee; + overflow-x: hidden; + + &.invited { + .user { + color: $color-grey; + } + } + + &:focus, + &:hover { + background: #fafafa; + + .button { + opacity: 1; + } + } +} + +.role { + display: block; + margin-bottom: 4px; + color: $color-primary; + font-size: 12px; +} + +.user { + display: flex; + align-items: center; + color: $color-black; + font-size: 15px; +} + +.user-picture { + width: 17px; + height: 17px; + margin-right: 6px; + border-radius: 3px; +} + +.invite { + color: $color-grey; + font-size: 13px; + font-style: italic; +} + +.button { + flex: 0 0 auto; + transition: $transition-speed $transition-easing; + transition-property: opacity; + opacity: 0; + margin-left: 5px; + font-size: 11px; +} + +.ember-power-select-trigger { + min-width: 150px; + margin-bottom: 10px; + border: 1px solid #eee; + background: #fff; + font-size: 12px; + box-shadow: 0 1px 5px rgba(#000, 0.06); +} diff --git a/webapp/app/pods/components/project-settings/collaborators/list/item/template.hbs b/webapp/app/pods/components/project-settings/collaborators/list/item/template.hbs new file mode 100644 index 00000000..64859a3b --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/list/item/template.hbs @@ -0,0 +1,74 @@ +
    +
    + {{#if isEditing}} + {{#power-select + searchEnabled=false + selected=roleValue + options=mappedPossibleRoles + onchange=(action (mut updatedRole) value='value') as |option| + }} + {{t option.label}} + {{/power-select}} + {{else}} + {{role}} + {{/if}} + + + {{#if collaborator.user.pictureUrl}} + + {{/if}} + + {{fullnameWithFallback}} + +
    + +
    + + {{#if hasJoined}} + {{t 'components.project_settings.collaborators_item.joined'}} + {{time-ago-in-words-tag date=collaborator.insertedAt}} + {{else}} + {{t 'components.project_settings.collaborators_item.invited'}} + {{time-ago-in-words-tag date=collaborator.insertedAt}} + + {{#if collaborator.assigner}} + {{t 'components.project_settings.collaborators_item.by'}} + {{collaborator.assigner.fullname}} + {{/if}} + {{/if}} + +
    +
    + +
    + {{#if isEditing}} + {{#if canUpdateCollaborator}} + + {{/if}} + + + {{else}} + {{#if canUpdateCollaborator}} + + {{/if}} + + {{#if canDeleteCollaborator}} + + {{/if}} + {{/if}} +
    diff --git a/webapp/app/pods/components/project-settings/collaborators/list/template.hbs b/webapp/app/pods/components/project-settings/collaborators/list/template.hbs new file mode 100644 index 00000000..6ce22821 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/list/template.hbs @@ -0,0 +1,10 @@ +
      + {{#each filteredCollaborators key='id' as |collaborator|}} + {{project-settings/collaborators/list/item + collaborator=collaborator + permissions=permissions + onUpdate=(action 'updateCollaborator') + onDelete=(action 'deleteCollaborator') + }} + {{/each}} +
    diff --git a/webapp/app/pods/components/project-settings/collaborators/styles.scss b/webapp/app/pods/components/project-settings/collaborators/styles.scss new file mode 100644 index 00000000..bea9ba13 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/styles.scss @@ -0,0 +1,75 @@ +& { + position: relative; + margin-top: 30px; +} + +.title { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #eee; + padding-bottom: 6px; + margin-bottom: 6px; + color: $color-grey; + font-weight: bold; + font-size: 17px; +} + +.toggleCreateFormButton { + position: absolute; + top: 0; + right: 0; +} + +.createForm { + background: #fafafa; + border: 1px solid #eee; + padding: 10px; + margin-top: 10px; +} + +.columns { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-top: 20px; +} + +.columns-item:first-of-type { + flex: 1 0 65%; + margin-right: 25px; +} + +.columns-item:last-of-type { + flex: 1 1 100%; +} + +.rolesList { + background: $color-white; + padding: 10px; + box-shadow: 0 1px 9px rgba(0, 0, 0, 0.08); +} + +.rolesList-title { + display: block; + padding-bottom: 5px; + margin: 10px 0 5px; + border-bottom: 1px solid #eee; + color: $color-primary; + font-size: 12px; + + &:first-of-type { + margin-top: 0; + } +} + +.rolesList-text { + color: $color-grey; + font-size: 12px; +} + +@media (max-width: ($screen-sm)) { + .rolesList { + display: none; + } +} diff --git a/webapp/app/pods/components/project-settings/collaborators/template.hbs b/webapp/app/pods/components/project-settings/collaborators/template.hbs new file mode 100644 index 00000000..3de247f1 --- /dev/null +++ b/webapp/app/pods/components/project-settings/collaborators/template.hbs @@ -0,0 +1,46 @@ +{{project-settings/title title=(t 'components.project_settings.collaborators.title')}} + +{{#if (get permissions 'create_collaborator')}} + +{{/if}} + +{{#if showCreateForm}} +
    + {{project-settings/collaborators/create-form + project=project + onCancel=(action 'toggleCreateForm') + onCreate=onCreateCollaborator + }} +
    +{{/if}} + +
    +
    + {{project-settings/collaborators/list + permissions=permissions + collaborators=collaborators + onDelete=onDeleteCollaborator + onUpdate=onUpdateCollaborator + }} +
    + +
    + {{t 'general.roles.OWNER'}} +

    {{t 'components.project_settings.collaborators.owner_text'}}

    + + {{t 'general.roles.ADMIN'}} +

    {{t 'components.project_settings.collaborators.admin_text'}}

    + + {{t 'general.roles.DEVELOPER'}} +

    {{t 'components.project_settings.collaborators.developer_text'}}

    + + {{t 'general.roles.REVIEWER'}} +

    {{t 'components.project_settings.collaborators.reviewer_text'}}

    +
    +
    diff --git a/webapp/app/pods/components/project-settings/delete-form/component.js b/webapp/app/pods/components/project-settings/delete-form/component.js new file mode 100644 index 00000000..129bc390 --- /dev/null +++ b/webapp/app/pods/components/project-settings/delete-form/component.js @@ -0,0 +1,12 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// onSubmit: Function +export default Component.extend({ + actions: { + deleteProject() { + this.onSubmit(); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/delete-form/styles.scss b/webapp/app/pods/components/project-settings/delete-form/styles.scss new file mode 100644 index 00000000..5bd74eba --- /dev/null +++ b/webapp/app/pods/components/project-settings/delete-form/styles.scss @@ -0,0 +1,36 @@ +& { + margin-top: 90px; +} + +.title { + display: block; + width: 100%; + padding-bottom: 6px; + border-bottom: 1px solid lighten($color-error, 48%); + font-size: 19px; + color: darken($color-error, 10%); +} + +.zone { + margin-top: 12px; + padding: 10px; + border-radius: 3px; + background: #fafafa; +} + +.zone-content { + display: flex; + align-items: center; + justify-content: space-between; +} + +.zone-title { + font-size: 13px; + color: darken($color-error, 26%); +} + +.zone-text { + font-size: 12px; + font-style: italic; + color: rgba(darken($color-error, 30%), 0.5); +} diff --git a/webapp/app/pods/components/project-settings/delete-form/template.hbs b/webapp/app/pods/components/project-settings/delete-form/template.hbs new file mode 100644 index 00000000..0ab25a62 --- /dev/null +++ b/webapp/app/pods/components/project-settings/delete-form/template.hbs @@ -0,0 +1,19 @@ + + {{t 'components.project_settings.delete_form.title'}} + + +
    +
    + {{t 'components.project_settings.delete_form.delete_project_title'}} +
    +

    {{t 'components.project_settings.delete_form.delete_project_text'}}

    + + {{#async-button + onClick=(action 'deleteProject') + class='button button--filled button--red' + }} + {{t 'components.project_settings.delete_form.delete_project_button'}} + {{/async-button}} +
    +
    +
    diff --git a/webapp/app/pods/components/project-settings/form/component.js b/webapp/app/pods/components/project-settings/form/component.js new file mode 100644 index 00000000..2a0d4de4 --- /dev/null +++ b/webapp/app/pods/components/project-settings/form/component.js @@ -0,0 +1,22 @@ +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes +// project: Object +// permissions: Ember Object containing +// onUpdateProject: Function +export default Component.extend({ + name: reads('project.name'), + isFileOperationsLocked: reads('project.isFileOperationsLocked'), + + actions: { + setLockedFileOperations() { + this.toggleProperty('isFileOperationsLocked'); + this.onUpdateProject(this.getProperties('isFileOperationsLocked', 'name')); + }, + + updateProject() { + this.onUpdateProject(this.getProperties('isFileOperationsLocked', 'name')); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/form/styles.scss b/webapp/app/pods/components/project-settings/form/styles.scss new file mode 100644 index 00000000..e7b1b5ba --- /dev/null +++ b/webapp/app/pods/components/project-settings/form/styles.scss @@ -0,0 +1,84 @@ +& { + margin-top: 25px; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 10px; + margin-right: 25px; + min-width: 250px; + font-family: $font-primary; + font-size: 12px; +} + +.lock { + display: flex; + align-items: center; + margin: 20px 0; + padding: 10px; + background: #fafafa; +} + +.lock-text-helper { + font-size: 11px; + font-style: italic; + color: #555; +} + +.lock-text { + display: inline-flex; + align-items: center; + margin-right: 12px; + font-size: 12px; + font-weight: bold; + cursor: pointer; + border: 1px solid; + background: #fff; + border-bottom-width: 2px; + padding: 3px 12px 3px 5px; + border-radius: 3px; + + &.lock-text--inactive { + color: #aaa; + border-color: #ddd; + + &:hover { + color: $color-primary; + border-color: $color-primary; + + .lock-icon { + fill: $color-primary; + } + } + + .lock-icon { + fill: #aaa; + } + } + + &.lock-text--active { + color: $color-error; + border-color: $color-error; + + &:hover { + color: darken($color-error, 10%); + border-color: darken($color-error, 10%); + + .lock-icon { + fill: darken($color-error, 10%); + } + } + + .lock-icon { + fill: $color-error; + } + } +} + +.lock-icon { + width: 20px; + height: 20px; + margin-right: 6px; +} diff --git a/webapp/app/pods/components/project-settings/form/template.hbs b/webapp/app/pods/components/project-settings/form/template.hbs new file mode 100644 index 00000000..566f3ef8 --- /dev/null +++ b/webapp/app/pods/components/project-settings/form/template.hbs @@ -0,0 +1,35 @@ +{{#if (get permissions 'update_project')}} +
    + {{input + value=name + class='textInput' + }} + + {{#async-button + onClick=(action 'updateProject') + class='button button--filled' + }} + {{t 'components.project_settings.form.update_button'}} + {{/async-button}} +
    +{{/if}} + +{{#if (get permissions 'lock_project_file_operations')}} +
    + {{#if isFileOperationsLocked}} +
    + {{inline-svg 'assets/lock--unlocked'onPath class='lock-icon lock-icon--unlocked'}} + {{t 'components.project_settings.form.lock_file_operations.remove_lock_button'}} +
    + {{else}} +
    + {{inline-svg 'assets/lock--locked' class='lock-icon lock-icon--locked'}} + {{t 'components.project_settings.form.lock_file_operations.add_lock_button'}} +
    + {{/if}} + +

    + {{t 'components.project_settings.form.lock_file_operations.text_1'}} +

    +
    +{{/if}} diff --git a/webapp/app/pods/components/project-settings/integrations/component.js b/webapp/app/pods/components/project-settings/integrations/component.js new file mode 100644 index 00000000..a7b407b8 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/component.js @@ -0,0 +1,22 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// permissions: Ember Object containing +// integration: Array of +// onCreateIntegration: Function +// onUpdateIntegration: Function +// onDeleteIntegration: Function +export default Component.extend({ + showCreateForm: false, + + actions: { + toggleCreateForm() { + this.set('showCreateForm', !this.showCreateForm); + }, + + create(args) { + return this.onCreateIntegration(args).then(() => this.set('showCreateForm', false)); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/integrations/form/component.js b/webapp/app/pods/components/project-settings/integrations/form/component.js new file mode 100644 index 00000000..fcbfbf10 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/component.js @@ -0,0 +1,79 @@ +import {computed} from '@ember/object'; +import {not, reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// project: Object +// integration: Object +// onSubmit: Function +export default Component.extend({ + isSubmiting: false, + emptyUrl: not('url'), + url: reads('integration.data.url'), + services: ['SLACK'], + + service: computed('services.[]', function() { + return this.services[0]; + }), + + serviceValue: computed('service', 'mappedServices', function() { + return this.mappedServices.find(({value}) => value === this.service); + }), + + mappedServices: computed('services', function() { + return this.services.map(value => { + return {label: `general.integration_services.${value}`, value}; + }); + }), + + syncChecked: computed('integration.events.[]', function() { + return this.integration.events.includes('SYNC'); + }), + + events: computed('syncChecked', function() { + const data = []; + + if (this.syncChecked) data.push('SYNC'); + + return data; + }), + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.integration) { + this.set('integration', { + newRecord: true, + service: this.services[0], + events: [], + data: { + url: '' + } + }); + } + }, + + actions: { + submit() { + this.set('isSubmiting', true); + + return this.onSubmit({ + service: this.service, + events: this.events, + integration: this.integration.newRecord ? null : this.integration, + data: { + url: this.url + } + }) + .then(() => this.set('isSubmiting', false)) + .then(() => { + if (this.integration.newRecord) { + this.setProperties({ + syncChecked: false, + url: '' + }); + } + }); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/integrations/form/styles.scss b/webapp/app/pods/components/project-settings/integrations/form/styles.scss new file mode 100644 index 00000000..31dd3a6c --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/styles.scss @@ -0,0 +1,61 @@ +& { + background: #fafafa; + border: 1px solid #eee; + padding: 10px; +} + +.form { + display: flex; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 8px 10px; + margin-right: 10px; + min-width: 350px; + font-family: $font-primary; + font-size: 11px; +} + +.checkbox-with-label { + display: inline-flex; + align-items: center; + margin-right: 10px; + + input { + margin-right: 3px; + } +} + +.events { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; + font-size: 12px; +} + +.events-title { + margin-bottom: 8px; + font-size: 13px; + font-weight: bold; +} + +.ember-power-select-trigger { + min-width: 150px; + margin-right: 10px; + border-color: #eee; + background: #fff; + font-size: 12px; + box-shadow: 0 1px 5px rgba(#000, 0.06); +} + +.ember-power-select-trigger[aria-disabled="true"] { + background: transparent; + box-shadow: none; +} + +.button { + margin-right: 5px; +} diff --git a/webapp/app/pods/components/project-settings/integrations/form/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/template.hbs new file mode 100644 index 00000000..0b465223 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/template.hbs @@ -0,0 +1,45 @@ +
    +
    + {{#power-select + disabled=true + searchEnabled=false + selected=serviceValue + options=mappedServices + onchange=(action (mut service) value='value') as |option| + }} + {{t option.label}} + {{/power-select}} + + {{input + value=url + class='textInput' + }} + + {{#async-button + onClick=(action 'submit') + loading=isCreating + disabled=emptyUrl + class='button button--filled' + }} + {{t 'components.project_settings.integrations.save'}} + {{/async-button}} + + {{#if onCancel}} + + {{/if}} +
    + +
    +

    + {{t 'components.project_settings.integrations.events.title'}} +

    + + {{checkbox-with-label + checked=syncChecked + text=(t 'components.project_settings.integrations.events.options.sync') + update=(action (mut syncChecked)) + }} +
    +
    diff --git a/webapp/app/pods/components/project-settings/integrations/list/component.js b/webapp/app/pods/components/project-settings/integrations/list/component.js new file mode 100644 index 00000000..27b71830 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/component.js @@ -0,0 +1,10 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// integrations: Array of +// onUpdateIntegration: Function +// onDeleteIntegration: Function +export default Component.extend({ + tagName: 'ul' +}); diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/component.js b/webapp/app/pods/components/project-settings/integrations/list/item/component.js new file mode 100644 index 00000000..adb00ee3 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/component.js @@ -0,0 +1,42 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; +const LOGOS = { + SLACK: 'assets/services/slack.svg' +}; + +// Attributes +// integration: Object +// onUpdate: Function +// onDelete: Function +export default Component.extend({ + i18n: service(), + + tagName: 'li', + + isEditing: false, + + logoService: computed('integration.service', function() { + return LOGOS[this.integration.service]; + }), + + mappedService: computed('integration.service', function() { + return this.i18n.t(`general.integration_services.${this.integration.service}`); + }), + + actions: { + toggleEdit() { + this.set('isEditing', !this.isEditing); + }, + + update(args) { + return this.onUpdate(args).then(() => this.set('isEditing', false)); + }, + + delete() { + this.set('isDeleting', true); + + this.onDelete({id: this.integration.id}).then(() => this.set('isDeleting', false)); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss b/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss new file mode 100644 index 00000000..8288f2e8 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss @@ -0,0 +1,45 @@ +& { + margin-bottom: 10px; +} + +.details { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border: 1px solid #eee; + border-radius: 3px; +} + +.details-info { + display: flex; + align-items: center; + overflow-x: hidden; + flex: 1 1 auto; +} + +.details-service { + font-size: 13px; + font-weight: bold; + color: #333; +} + +.details-url { + padding-right: 15px; + text-overflow: ellipsis; + overflow-x: hidden; + margin-left: 10px; + font-size: 13px; + color: #ddd; +} + +.details-logo { + flex: 0 0 22px; + margin-right: 4px; + width: 22px; + height: 22px; +} + +.details-actions { + flex: 0 0 auto; +} diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs b/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs new file mode 100644 index 00000000..2539e1d3 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs @@ -0,0 +1,33 @@ +{{#if isEditing}} + {{project-settings/integrations/form + integration=integration + onSubmit=(action 'update') + onCancel=(action 'toggleEdit') + }} +{{else}} +
    +
    + {{inline-svg logoService class='details-logo'}} + {{mappedService}} + {{integration.data.url}} +
    + +
    + {{#if (get permissions 'update_integration')}} + + {{/if}} + + {{#if (get permissions 'delete_integration')}} + {{#async-button + onClick=(action 'delete') + loading=isDeleting + class='button button--filled button--red' + }} + {{t 'components.project_settings.integrations.delete'}} + {{/async-button}} + {{/if}} +
    +
    +{{/if}} diff --git a/webapp/app/pods/components/project-settings/integrations/list/styles.scss b/webapp/app/pods/components/project-settings/integrations/list/styles.scss new file mode 100644 index 00000000..85d7ff2b --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/styles.scss @@ -0,0 +1,3 @@ +& { + margin-top: 10px; +} diff --git a/webapp/app/pods/components/project-settings/integrations/list/template.hbs b/webapp/app/pods/components/project-settings/integrations/list/template.hbs new file mode 100644 index 00000000..4d539602 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/template.hbs @@ -0,0 +1,8 @@ +{{#each integrations key='id' as |integration|}} + {{project-settings/integrations/list/item + permissions=permissions + integration=integration + onUpdate=onUpdate + onDelete=onDelete + }} +{{/each}} diff --git a/webapp/app/pods/components/project-settings/integrations/styles.scss b/webapp/app/pods/components/project-settings/integrations/styles.scss new file mode 100644 index 00000000..aff82a99 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/styles.scss @@ -0,0 +1,29 @@ +& { + position: relative; + margin-top: 30px; +} + +.title { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #eee; + padding-bottom: 6px; + margin-bottom: 6px; + color: $color-grey; + font-weight: bold; + font-size: 17px; +} + +.toggleCreateFormButton { + position: absolute; + top: 0; + right: 0; +} + +.help { + margin: 10px 0; + font-size: 13px; + font-style: italic; + color: #333; +} diff --git a/webapp/app/pods/components/project-settings/integrations/template.hbs b/webapp/app/pods/components/project-settings/integrations/template.hbs new file mode 100644 index 00000000..2189c0a1 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/template.hbs @@ -0,0 +1,32 @@ +{{project-settings/title title=(t 'components.project_settings.integrations.title')}} + +{{#if (get permissions 'create_integration')}} + +{{/if}} + +

    + {{t 'components.project_settings.integrations.help'}} +

    + +{{#if showCreateForm}} +
    + {{project-settings/integrations/form + project=project + onCancel=(action 'toggleCreateForm') + onSubmit=(action 'create') + }} +
    +{{/if}} + +{{project-settings/integrations/list + permissions=permissions + integrations=project.integrations + onUpdate=onUpdateIntegration + onDelete=onDeleteIntegration +}} diff --git a/webapp/app/pods/components/project-settings/links-list/styles.scss b/webapp/app/pods/components/project-settings/links-list/styles.scss new file mode 100644 index 00000000..71b4ac94 --- /dev/null +++ b/webapp/app/pods/components/project-settings/links-list/styles.scss @@ -0,0 +1,54 @@ +& { + margin-top: 30px; +} + +.list { + display: flex; + flex-wrap: wrap; + margin-top: 20px; +} + +.link { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 30px; + margin: 0 30px 30px 0; + min-width: 160px; + text-align: center; + background: #fff; + border-radius: 3px; + color: #999; + box-shadow: 0 1px 4px rgba(#000, 0.15); + border: 1px solid transparent; + text-decoration: none; + font-weight: 600; + font-size: 13px; + transition: $transition-speed $transition-easing; + transition-property: background, border-color, color; + + .link-icon { + fill: #999; + } + + &:focus, + &:hover { + color: lighten($color-primary, 5%); + border-color: lighten($color-primary, 45%); + background: lighten($color-primary, 51%); + + .link-icon { + fill: $color-primary; + } + } +} + +.link-icon { + width: 20px; + height: 20px; + margin-bottom: 3px; + fill: $color-primary; + transition: $transition-speed $transition-easing; + transition-property: fill; +} diff --git a/webapp/app/pods/components/project-settings/links-list/template.hbs b/webapp/app/pods/components/project-settings/links-list/template.hbs new file mode 100644 index 00000000..7ad1438e --- /dev/null +++ b/webapp/app/pods/components/project-settings/links-list/template.hbs @@ -0,0 +1,41 @@ +
    + {{#link-to + 'logged-in.project.edit.collaborators' + class='link' + }} + {{inline-svg 'assets/users.svg' class='link-icon'}} + {{t 'components.project_settings.links_list.collaborators'}} + {{/link-to}} + + {{#link-to + 'logged-in.project.edit.badges' + class='link' + }} + {{inline-svg 'assets/badge.svg' class='link-icon'}} + {{t 'components.project_settings.links_list.badges'}} + {{/link-to}} + + {{#link-to + 'logged-in.project.edit.api-token' + class='link' + }} + {{inline-svg 'assets/bot.svg' class='link-icon'}} + {{t 'components.project_settings.links_list.api_token'}} + {{/link-to}} + + {{#link-to + 'logged-in.project.edit.service-integrations' + class='link' + }} + {{inline-svg 'assets/share.svg' class='link-icon'}} + {{t 'components.project_settings.links_list.service_integrations'}} + {{/link-to}} + + {{#link-to + 'logged-in.project.edit.manage-languages' + class='link' + }} + {{inline-svg 'assets/language.svg' class='link-icon'}} + {{t 'components.project_settings.links_list.manage_languages'}} + {{/link-to}} +
    diff --git a/webapp/app/pods/components/project-settings/manage-languages/component.js b/webapp/app/pods/components/project-settings/manage-languages/component.js new file mode 100644 index 00000000..d4aeb9f2 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/component.js @@ -0,0 +1,12 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// permissions: Ember Object containing +// languages: Array of +// revisions: Array of +// errors: Array of String +// onPromoteMaster: Function +// onDelete: Function +// onCreate: Function +export default Component.extend(); diff --git a/webapp/app/pods/components/project-settings/manage-languages/create-form/component.js b/webapp/app/pods/components/project-settings/manage-languages/create-form/component.js new file mode 100644 index 00000000..7ffb376a --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/create-form/component.js @@ -0,0 +1,57 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {htmlSafe} from '@ember/string'; +import {not, reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// languages: Array of +// onCreate: Function +export default Component.extend({ + languageSearcher: service('language-searcher'), + + languagesCopy: reads('languages'), + isLoading: false, + + emptyLanguage: not('language'), + + language: computed('mappedLanguages.[]', function() { + const first = this.mappedLanguages[0]; + + return first ? first.value : null; + }), + + languageValue: computed('language', 'mappedLanguages.[]', function() { + return this.mappedLanguages.find(({value}) => value === this.language); + }), + + mappedLanguages: computed('languagesCopy.[]', function() { + if (!this.languagesCopy) return []; + + return this._mapLanguages(this.languagesCopy); + }), + + actions: { + submit() { + this.set('isLoading', true); + + this.onCreate(this.language).then(() => this.set('isLoading', false)); + }, + + searchLanguages(term) { + return this.languageSearcher.search({term}).then(languages => { + this.set('languagesCopy', languages); + + return this._mapLanguages(languages); + }); + } + }, + + _mapLanguages(languages) { + return languages.map(({id, name, slug}) => { + const label = htmlSafe(`${name} ${slug}`); + + return {label, value: id}; + }); + } +}); diff --git a/webapp/app/pods/components/project-settings/manage-languages/create-form/template.hbs b/webapp/app/pods/components/project-settings/manage-languages/create-form/template.hbs new file mode 100644 index 00000000..924ba650 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/create-form/template.hbs @@ -0,0 +1,19 @@ +{{#power-select + search=(action 'searchLanguages') + options=mappedLanguages + selected=languageValue + placeholder=(t 'components.project_manage_languages_create_form.language_search_placeholder') + searchPlaceholder=(t 'components.project_manage_languages_create_form.language_search_placeholder') + onchange=(action (mut language) value='value') as |option| +}} + {{option.label}} +{{/power-select}} + +{{#async-button + onClick=(action 'submit') + class='button button--filled' + loading=isLoading + disabled=emptyLanguage +}} + {{t 'components.project_manage_languages_create_form.save_button'}} +{{/async-button}} diff --git a/webapp/app/pods/components/project-settings/manage-languages/overview/component.js b/webapp/app/pods/components/project-settings/manage-languages/overview/component.js new file mode 100644 index 00000000..9820d43b --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/overview/component.js @@ -0,0 +1,9 @@ +import Component from '@ember/component'; + +// Attributes +// project: Object +// permissions: Ember Object containing +// revisions: Array of +// onPromoteMaster: Function +// onDelete: Function +export default Component.extend(); diff --git a/webapp/app/pods/components/project-settings/manage-languages/overview/item/component.js b/webapp/app/pods/components/project-settings/manage-languages/overview/item/component.js new file mode 100644 index 00000000..550a5dd8 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/overview/item/component.js @@ -0,0 +1,52 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes +// revision: Object +// permissions: Ember Object containing +// onPromoteMaster: Function +// onDelete: Function +export default Component.extend({ + i18n: service(), + + classNames: ['list-item'], + classNameBindings: [ + 'master:list-item--master', + 'isPromoting:list-item--promoting', + 'isDeleting:list-item--deleting', + 'deleted:list-item--deleted' + ], + + isPromoting: false, + isDeleting: false, + isDeleted: false, + + actions: { + promoteRevision() { + /* eslint-disable no-alert */ + if (!window.confirm(this.i18n.t('components.project_manage_languages_overview.promote_revision_master_confirm'))) { + return; + } + /* eslint-enable no-alert */ + + this.set('isPromoting', true); + this.onPromoteMaster(this.revision).then(() => this.set('isPromoting', false)); + }, + + deleteRevision() { + /* eslint-disable no-alert */ + if (!window.confirm(this.i18n.t('components.project_manage_languages_overview.delete_revision_confirm'))) return; + /* eslint-enable no-alert */ + + this.set('isDeleting', true); + this.onDelete(this.revision) + .then(() => { + this.setProperties({ + isDeleting: false, + isDeleted: true + }); + }) + .catch(() => this.set('isDeleting', false)); + } + } +}); diff --git a/webapp/app/pods/components/project-settings/manage-languages/overview/item/template.hbs b/webapp/app/pods/components/project-settings/manage-languages/overview/item/template.hbs new file mode 100644 index 00000000..d68faab2 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/overview/item/template.hbs @@ -0,0 +1,49 @@ +
    + {{#link-to + 'logged-in.project.revision.translations' + project.id + revision.id + class='list-link' + }} + {{revision.language.name}} + {{/link-to}} + + {{#if revision.isMaster}} + {{#acc-badge class='masterBadge'}} + {{t 'components.project_manage_languages_overview.master_badge'}} + {{/acc-badge}} + {{/if}} +
    + +
    + {{#unless revision.isMaster}} +
    + {{t 'components.project_manage_languages_overview.revision_inserted_at_label'}} + {{time-ago-in-words-tag date=revision.insertedAt}} +
    + +
    + {{#if (get permissions 'promote_slave')}} + {{#async-button + loading=isPromoting + class='button--grey' + onClick=(action 'promoteRevision') + }} + {{inline-svg '/assets/chevron-top.svg' class='button-icon'}} + {{t 'components.project_manage_languages_overview.promote_revision_master_button'}} + {{/async-button}} + {{/if}} + + {{#if (get permissions 'delete_slave')}} + {{#async-button + loading=isDeleting + class='button--red button--filled' + onClick=(action 'deleteRevision') + }} + {{inline-svg '/assets/x.svg' class='button-icon'}} + {{t 'components.project_manage_languages_overview.delete_revision_button'}} + {{/async-button}} + {{/if}} +
    + {{/unless}} +
    diff --git a/webapp/app/pods/components/project-settings/manage-languages/overview/styles.scss b/webapp/app/pods/components/project-settings/manage-languages/overview/styles.scss new file mode 100644 index 00000000..fc46563a --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/overview/styles.scss @@ -0,0 +1,82 @@ +& { + margin-top: 20px; + max-width: 550px; +} + +.list { + margin: 15px 0 0; +} + +.list-item { + margin-bottom: 5px; + padding: 8px 2px; + border-bottom: 1px solid rgba($color-black, 0.1); + font-size: 14px; + + &.list-item--deleted { + display: none; + } + + &.list-item--deleting { + .list-item-actions { + pointer-events: all; + opacity: 1; + } + } + + &.list-item--master { + padding: 10px 2px; + margin-bottom: 10px; + border-bottom-color: rgba($color-black, 0.2); + font-size: 16px; + } + + &:focus, + &:hover { + .list-item-actions { + pointer-events: all; + opacity: 1; + } + } +} + +.list-item-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-item-infos { + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-item-infos-date { + font-size: 12px; + color: $color-grey; +} + +.list-link { + display: inline-flex; + align-items: center; + text-decoration: none; + color: $color-primary; + + &:focus, + &:hover { + text-decoration: underline; + } +} + +.list-item-actions { + display: flex; + opacity: 0; + pointer-events: none; + transition: $transition-speed $transition-easing; + transition-property: opacity; +} + +.masterBadge { + margin-left: 10px; +} diff --git a/webapp/app/pods/components/project-settings/manage-languages/overview/template.hbs b/webapp/app/pods/components/project-settings/manage-languages/overview/template.hbs new file mode 100644 index 00000000..24b61141 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/overview/template.hbs @@ -0,0 +1,13 @@ +{{project-settings/title title=(t 'components.project_manage_languages_overview.list_languages')}} + +
      + {{#each revisions key='id' as |revision|}} + {{project-settings/manage-languages/overview/item + master=revision.isMaster + permissions=permissions + onPromoteMaster=onPromoteMaster + onDelete=onDelete + revision=revision + }} + {{/each}} +
    diff --git a/webapp/app/pods/components/project-settings/manage-languages/styles.scss b/webapp/app/pods/components/project-settings/manage-languages/styles.scss new file mode 100644 index 00000000..3d2f170e --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/styles.scss @@ -0,0 +1,51 @@ +& { + margin-top: 25px; +} + +.titleText { + display: flex; + align-items: center; + margin: 15px 0 5px; + font-weight: 600; + font-size: 14px; + color: $color-black; + + &:first-of-type { + margin-top: 0; + } +} + +.explainText { + display: block; + max-width: 700px; + font-size: 14px; + color: rgba($color-black, 0.8); +} + +.emptyLanguages { + font-size: 14px; + padding: 10px; + background: #fafafa; + border: 1px solid #eee; + border-radius: 3px; + color: $color-grey; +} + +.createSlaveForm { + margin-top: 20px; +} + +.error { + margin-bottom: 10px; + font-size: 12px; + font-weight: bold; + color: $color-error; +} + +.ember-power-select-trigger { + min-height: 31px; + max-width: 400px; + margin-bottom: 20px; + background: #fafafa; + border: 1px solid #eee; +} diff --git a/webapp/app/pods/components/project-settings/manage-languages/template.hbs b/webapp/app/pods/components/project-settings/manage-languages/template.hbs new file mode 100644 index 00000000..c53fb2a3 --- /dev/null +++ b/webapp/app/pods/components/project-settings/manage-languages/template.hbs @@ -0,0 +1,55 @@ +

    + {{inline-svg '/assets/sync.svg' class='button-icon'}} + {{t 'components.project_manage_languages.sync_explain_title'}} +

    + +

    + {{t 'components.project_manage_languages.sync_explain_text'}} +

    + +

    + {{inline-svg '/assets/merge.svg' class='button-icon'}} + {{t 'components.project_manage_languages.add_translations_explain_title'}} +

    + +

    + {{t 'components.project_manage_languages.add_translations_explain_text'}} +

    + +

    + {{inline-svg '/assets/check.svg' class='button-icon'}} + {{t 'components.project_manage_languages.conflicts_explain_title'}} +

    + +

    + {{t 'components.project_manage_languages.conflicts_explain_text'}} +

    + +
    + +{{project-settings/manage-languages/overview + permissions=permissions + project=project + revisions=revisions + onPromoteMaster=onPromoteMaster + onDelete=onDelete +}} + +{{#if (get permissions 'create_slave')}} +
    + {{#if errors}} +
    + {{#each errors as |error|}} +
  • {{error}}
  • + {{/each}} +
    + {{/if}} + + {{project-settings/manage-languages/create-form + permissions=permissions + project=project + languages=languages + onCreate=onCreate + }} +
    +{{/if}} diff --git a/webapp/app/pods/components/project-settings/title/component.js b/webapp/app/pods/components/project-settings/title/component.js new file mode 100644 index 00000000..da5fcb72 --- /dev/null +++ b/webapp/app/pods/components/project-settings/title/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'h2' +}); diff --git a/webapp/app/pods/components/project-settings/title/styles.scss b/webapp/app/pods/components/project-settings/title/styles.scss new file mode 100644 index 00000000..0ba74934 --- /dev/null +++ b/webapp/app/pods/components/project-settings/title/styles.scss @@ -0,0 +1,5 @@ +& { + font-size: 18px; + font-weight: bold; + color: $color-black; +} diff --git a/webapp/app/pods/components/project-settings/title/template.hbs b/webapp/app/pods/components/project-settings/title/template.hbs new file mode 100644 index 00000000..ab17eedc --- /dev/null +++ b/webapp/app/pods/components/project-settings/title/template.hbs @@ -0,0 +1 @@ +{{title}} diff --git a/webapp/app/pods/components/projects-filters/component.js b/webapp/app/pods/components/projects-filters/component.js new file mode 100644 index 00000000..450d047d --- /dev/null +++ b/webapp/app/pods/components/projects-filters/component.js @@ -0,0 +1,30 @@ +import {observer} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; +import {run} from '@ember/runloop'; + +const DEBOUNCE_OFFSET = 500; // ms + +// Attributes: +// query: String +// onChangeQuery: Function +export default Component.extend({ + session: service(), + + debouncedQuery: reads('query'), + + queryDidChanges: observer('debouncedQuery', function() { + run.debounce(this, this._debounceQuery, DEBOUNCE_OFFSET); + }), + + _debounceQuery() { + this.onChangeQuery(this.debouncedQuery); + }, + + actions: { + submitForm() { + this._debounceQuery(); + } + } +}); diff --git a/webapp/app/pods/components/projects-filters/styles.scss b/webapp/app/pods/components/projects-filters/styles.scss new file mode 100644 index 00000000..b4ea4fc1 --- /dev/null +++ b/webapp/app/pods/components/projects-filters/styles.scss @@ -0,0 +1,45 @@ +& { + margin: 40px auto 0; + max-width: $screen-lg; +} + +.filters-wrapper { + display: flex; + align-items: center; + justify-content: space-between; +} + +.queryFilter { + flex: 1; + margin-right: 15px; +} + +.totalEntries { + margin-left: 20px; + color: $color-grey; + font-size: 13px; + font-style: italic; +} + +.search-icon { + position: absolute; + top: 50%; + margin-top: -10px; + left: 7px; + width: 20px; + height: 20px; + fill: #b7b7b7; +} + +.input { + @extend %textInput; + width: 100%; + padding: 7px 7px 7px 30px; + font-size: 14px; + font-family: $font-primary; + color: $color-black; + + &::placeholder { + color: $color-grey; + } +} diff --git a/webapp/app/pods/components/projects-filters/template.hbs b/webapp/app/pods/components/projects-filters/template.hbs new file mode 100644 index 00000000..61ae84ce --- /dev/null +++ b/webapp/app/pods/components/projects-filters/template.hbs @@ -0,0 +1,22 @@ +
    +
    +
    + {{inline-svg '/assets/search.svg' class='search-icon'}} + + {{input + class='input' + type='text' + placeholder=(t 'components.projects_filters.input_placeholder_text') + value=debouncedQuery + }} +
    + + {{#link-to + 'logged-in.projects.new' + class='button button--filled' + }} + {{inline-svg '/assets/add.svg' class='button-icon'}} + {{t 'components.projects_filters.new_project'}} + {{/link-to}} +
    +
    diff --git a/webapp/app/pods/components/projects-header/component.js b/webapp/app/pods/components/projects-header/component.js new file mode 100644 index 00000000..b766392f --- /dev/null +++ b/webapp/app/pods/components/projects-header/component.js @@ -0,0 +1,16 @@ +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; + +// Attributes: +// session: Service +export default Component.extend({ + tagName: 'header', + session: service('session'), + + actions: { + logout() { + this.session.logout(); + window.location = '/'; + } + } +}); diff --git a/webapp/app/pods/components/projects-header/styles.scss b/webapp/app/pods/components/projects-header/styles.scss new file mode 100644 index 00000000..0bba6a14 --- /dev/null +++ b/webapp/app/pods/components/projects-header/styles.scss @@ -0,0 +1,123 @@ +& { + padding: 14px 0; + box-shadow: 0 3px 10px rgba(#000, 0.1); + background: #041e14; +} + +.content { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 auto; + max-width: $screen-lg; + padding: 0 20px; + width: 100%; +} + +.content-right, +.content-left { + display: flex; + align-items: center; +} + +.project { + margin-left: 30px; + padding-left: 20px; + border-left: 1px solid rgba(#fff, 0.2); + font-size: 13px; + color: $color-grey; + text-shadow: 0 1px 3px rgba(#000, 0.6); +} + +.applicationLogo { + display: inline-flex; + align-items: center; + text-decoration: none; + + &:focus, + &:hover { + .applicationLogo-image--linked { + transform: rotate(-90deg); + } + } +} + +.link.active .link-image { + &:focus, + &:hover { + transform: rotate(0); + } +} + +.applicationLogo-image { + display: block; + transition: 1s $transition-easing; + transition-property: transform; + width: 25px; + height: 25px; + text-decoration: none; +} + +.applicationLogo-name { + margin-left: 15px; + font-size: 18px; + font-weight: 700; + color: $color-white; + text-shadow: 0 1px 3px rgba(#000, 0.6); +} + +.picture { + width: 18px; + height: 18px; + margin-right: 10px; + border-radius: 3px; + box-shadow: 0 0 3px #000; +} + +.username { + margin-right: 15px; + font-size: 12px; + color: $color-white; +} + +@media (max-width: ($screen-sm)) { + & { + padding: 9px 0; + margin-bottom: 10px; + } + + .applicationLogo-image { + width: 18px; + height: 18px; + } + + .project { + margin-left: 20px; + } + + .username { + font-size: 11px; + } + + .button { + padding: 3px 7px 4px; + font-size: 11px; + } + + .applicationLogo-name { + display: none; + } + + .project { + border: 0; + margin: 0 0 0 10px; + padding: 0; + font-weight: bold; + font-size: 14px; + color: $color-white; + } + + .content { + padding: 0 10px; + } +} diff --git a/webapp/app/pods/components/projects-header/template.hbs b/webapp/app/pods/components/projects-header/template.hbs new file mode 100644 index 00000000..409c5d4d --- /dev/null +++ b/webapp/app/pods/components/projects-header/template.hbs @@ -0,0 +1,35 @@ +
    +
    + {{#if project}} + {{#link-to + 'logged-in.projects' + class='applicationLogo' + }} + {{inline-svg 'assets/logo.svg' class='applicationLogo-image applicationLogo-image--linked'}} + + {{t 'general.application_name'}} + {{/link-to}} + +
    + {{project.name}} +
    + {{else}} + + {{/if}} +
    + + {{#if session.credentials.user}} +
    + {{#if session.credentials.user.picture_url}} + + {{/if}} + + {{session.credentials.user.fullname}} + +
    + {{/if}} +
    diff --git a/webapp/app/pods/components/projects-list/component.js b/webapp/app/pods/components/projects-list/component.js new file mode 100644 index 00000000..ae9fba39 --- /dev/null +++ b/webapp/app/pods/components/projects-list/component.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +// Attributes: +// projects: Array of +// query: String +export default Component.extend(); diff --git a/webapp/app/pods/components/projects-list/styles.scss b/webapp/app/pods/components/projects-list/styles.scss new file mode 100644 index 00000000..0a2f0b4b --- /dev/null +++ b/webapp/app/pods/components/projects-list/styles.scss @@ -0,0 +1,37 @@ +& { + margin: 0 auto; + max-width: $screen-lg; + position: relative; + margin-top: 20px; +} + +.item-link { + display: flex; + align-items: center; + justify-content: space-between; + transition: $transition-speed $transition-easing; + transition-property: background, color; + padding: 10px 10px 12px; + border-bottom: 1px solid #f5f5f5; + text-decoration: none; + color: $color-black; + + &:hover, + &:focus { + color: $color-primary; + background: $color-light-grey; + } +} + +.projectSyncedAt { + display: block; + margin-top: 4px; + color: $color-grey; + font-style: italic; + font-size: 11px; +} + +.projectLanguage { + color: $color-grey; + font-size: 12px; +} diff --git a/webapp/app/pods/components/projects-list/template.hbs b/webapp/app/pods/components/projects-list/template.hbs new file mode 100644 index 00000000..bac0b83b --- /dev/null +++ b/webapp/app/pods/components/projects-list/template.hbs @@ -0,0 +1,49 @@ +
      + {{#each projects key='id' as |project|}} +
    • + {{#link-to + 'logged-in.project' + project.id + class='item-link' + }} + + {{project.name}} + + {{#if project.lastSyncedAt}} + {{t 'components.projects_list.last_synced_at_label'}} + {{time-ago-in-words-tag + date=project.lastSyncedAt + class='lastSyncedAt-date' + }} + {{else}} + {{t 'components.projects_list.never_synced'}} + {{/if}} + + + + + {{project.language.name}} + + {{/link-to}} +
    • + {{else if query}} + {{empty-content + iconPath='assets/empty.svg' + text=(t 'components.projects_list.no_projects_query' query=query) + }} + {{else}} + {{#empty-content}} + {{inline-svg 'assets/empty.svg' class='icon'}} + {{t 'components.projects_list.no_projects'}} + + + {{/empty-content}} + {{/each}} +
    diff --git a/webapp/app/pods/components/quick-submit-textarea/component.js b/webapp/app/pods/components/quick-submit-textarea/component.js new file mode 100644 index 00000000..ad96cfc3 --- /dev/null +++ b/webapp/app/pods/components/quick-submit-textarea/component.js @@ -0,0 +1,19 @@ +import TextArea from '@ember/component/text-area'; + +const ENTER_KEY = 13; + +export default TextArea.extend({ + focusIn() { + if (this.onFocus) this.onFocus(); + }, + + focusOut() { + if (this.onBlur) this.onBlur(); + }, + + keyDown(event) { + if (event.which === ENTER_KEY && (event.metaKey || event.ctrlKey)) { + if (this.onSubmit) this.onSubmit(); + } + } +}); diff --git a/webapp/app/pods/components/related-translations-list/component.js b/webapp/app/pods/components/related-translations-list/component.js new file mode 100644 index 00000000..6079d7ae --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/component.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// translations: Array of object +export default Component.extend(); diff --git a/webapp/app/pods/components/related-translations-list/item/component.js b/webapp/app/pods/components/related-translations-list/item/component.js new file mode 100644 index 00000000..ea8de0f1 --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/item/component.js @@ -0,0 +1,42 @@ +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; +import {run} from '@ember/runloop'; + +// Attributes: +// translation: Object +// onUpdateText: Fucntion +export default Component.extend({ + tagName: 'li', + + classNameBindings: ['isInEditMode:item--editMode'], + + isSaving: false, + isInEditMode: false, + showEditButton: true, + editText: reads('translation.correctedText'), + + actions: { + save() { + this.set('isSaving', true); + + this.onUpdateText(this.translation, this.editText).then(() => { + this.set('isSaving', false); + + if (this.showEditButton) { + this.toggleProperty('isInEditMode'); + } + }); + }, + + toggleEdit() { + this.set('editText', this.translation.correctedText); + this.toggleProperty('isInEditMode'); + + if (this.isInEditMode) { + run.next(this, function() { + this.element.querySelector('.textEdit-input').focus(); + }); + } + } + } +}); diff --git a/webapp/app/pods/components/related-translations-list/item/styles.scss b/webapp/app/pods/components/related-translations-list/item/styles.scss new file mode 100644 index 00000000..56f2cfdf --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/item/styles.scss @@ -0,0 +1,126 @@ +& { + position: relative; + padding: 10px; + margin-bottom: 15px; + background: $color-white; + box-shadow: 0 1px 5px rgba($color-black, 0.04); + border: 1px solid $color-border; + border-radius: 3px; + + &.empty { + padding: 35px 10px; + color: $color-grey; + font-size: 13px; + font-style: italic; + text-align: center; + } + + &.item--editMode { + background: $color-light-grey; + border: 1px solid lighten($color-grey, 25%); + + .editButton { + opacity: 1; + } + + .editButton-icon { + &:focus, + &:hover { + fill: $color-error; + } + } + } + + &:hover, + &:focus { + .editButton { + opacity: 1; + } + } +} + +.textEmpty { + margin-bottom: 15px; +} + +.language-icon { + position: relative; + top: -2px; + width: 15px; + height: 15px; + margin-right: 6px; + opacity: 0.2; +} + +.key { + display: flex; + align-items: flex-end; + margin-right: 4px; + color: rgba($color-black, 0.7); + text-decoration: none; + font-weight: normal; + font-size: 12px; + + &:focus, + &:hover { + color: $color-primary; + } +} + +.updatedAt { + margin-left: 6px; + color: $color-grey; + font-size: 11px; + font-style: italic; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.text { + @extend %translationTextBase; + padding-top: 10px; + margin: 10px 0 0; + border-top: 1px solid $color-light-grey; + font-size: 13px; +} + +.textEdit { + margin-top: 10px; +} + +.textEdit-input { + @extend %textInput; + width: 100%; + padding: 10px; + margin-bottom: 6px; + font-size: 12px; +} + +.textEdit-cancel { + margin-right: 20px; + color: $color-grey; + font-size: 12px; + cursor: pointer; +} + +.textEdit-button { + padding: 1px 9px; +} + +.textEdit-actions { + display: flex; + justify-content: flex-end; + align-items: center; + margin-top: 10px; +} + +.textEdit-actions-warning { + margin-right: 10px; + font-style: italic; + font-size: 12px; + color: #bbb; +} diff --git a/webapp/app/pods/components/related-translations-list/item/template.hbs b/webapp/app/pods/components/related-translations-list/item/template.hbs new file mode 100644 index 00000000..4018ace8 --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/item/template.hbs @@ -0,0 +1,90 @@ +{{#if showEditButton}} + + + {{#if isInEditMode}} + {{inline-svg 'assets/x.svg' class='editButton-icon'}} + {{else}} + {{inline-svg 'assets/pencil.svg' class='editButton-icon'}} + {{/if}} + + +{{/if}} + +
    +
    + {{#link-to + 'logged-in.project.translation' + project.id + translation.id + class='key' + }} + {{inline-svg 'assets/language.svg' class='language-icon'}} + + {{translation.revision.language.name}} + + + {{t 'components.related_translations_list.last_updated_label'}} + {{time-ago-in-words-tag date=translation.updatedAt}} + + {{/link-to}} +
    + +
    + {{#unless translation.isRemoved}} + {{#if translation.commentsCount}} + {{#acc-badge link=true}} + {{#link-to + 'logged-in.project.translation.comments' + project.id + translation.id + }} + {{t 'components.related_translations_list.comments_label' count=translation.commentsCount}} + {{/link-to}} + {{/acc-badge}} + {{/if}} + {{#if translation.isConflicted}} + {{#acc-badge link=true primary=true}} + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + translation.revision.id + (query-params query=translation.key) + }} + {{t 'components.related_translations_list.conflicted_label'}} + {{/link-to}} + {{/acc-badge}} + {{/if}} + {{/unless}} +
    +
    + +{{#if isInEditMode}} +
    + {{translation-edit/form + disabled=translation.isRemoved + valueType=translation.valueType + value=editText + onSubmit=(action 'save') + }} + +
    + {{#unless translation.isConflicted}} + You’re editing an already reviewed string. + {{/unless}} + + {{#if showEditButton}} + Cancel + {{/if}} + + {{#async-button + onClick=(action 'save') + loading=isSaving + class='button button--filled button--iconOnly textEdit-button' + }} + Save + {{/async-button}} +
    +
    +{{else}} +
    {{translation.correctedText}}
    +{{/if}} diff --git a/webapp/app/pods/components/related-translations-list/styles.scss b/webapp/app/pods/components/related-translations-list/styles.scss new file mode 100644 index 00000000..f2d41b7a --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/styles.scss @@ -0,0 +1,20 @@ +& { + margin-top: 30px; +} + +.emptyItem { + padding: 25px 10px; + margin-bottom: 15px; + background: $color-white; + box-shadow: 0 1px 5px rgba($color-black, 0.04); + border: 1px solid $color-border; + border-radius: 3px; + color: $color-grey; + font-size: 13px; + font-style: italic; + text-align: center; +} + +.emptyItem-text { + margin-bottom: 15px; +} diff --git a/webapp/app/pods/components/related-translations-list/template.hbs b/webapp/app/pods/components/related-translations-list/template.hbs new file mode 100644 index 00000000..f9b52824 --- /dev/null +++ b/webapp/app/pods/components/related-translations-list/template.hbs @@ -0,0 +1,26 @@ +
      + {{#each translations key='id' as |translation|}} + {{related-translations-list/item + onUpdateText=onUpdateText + isInEditMode=true + showEditButton=false + translation=translation + project=project + }} + {{else}} +
    • +

      + {{t 'components.related_translations_list.no_related_translations'}} +

      + + {{#link-to + 'logged-in.project.edit.manage-languages' + project.id + class='button button--filled button--white button--borderless' + }} + {{inline-svg 'assets/add.svg' class='button-icon'}} + {{t 'components.related_translations_list.new_language_link'}} + {{/link-to}} +
    • + {{/each}} +
    diff --git a/webapp/app/pods/components/removed-translation-edit/component.js b/webapp/app/pods/components/removed-translation-edit/component.js new file mode 100644 index 00000000..56bcd0e5 --- /dev/null +++ b/webapp/app/pods/components/removed-translation-edit/component.js @@ -0,0 +1,4 @@ +import Component from '@ember/component'; + +// Attributes: +export default Component.extend(); diff --git a/webapp/app/pods/components/removed-translation-edit/styles.scss b/webapp/app/pods/components/removed-translation-edit/styles.scss new file mode 100644 index 00000000..99157965 --- /dev/null +++ b/webapp/app/pods/components/removed-translation-edit/styles.scss @@ -0,0 +1,21 @@ +.text { + width: 100%; + min-height: 140px; + margin-top: 30px; + padding: 15px; + resize: vertical; + outline: 0; + border: 1px solid lighten($color-grey, 20%); + background: darken($color-white, 2%); + font-family: $font-monospace; + font-size: 12px; +} + +.label { + display: block; + margin-top: 10px; + color: $color-grey; + font-size: 13px; + font-style: italic; + text-align: right; +} diff --git a/webapp/app/pods/components/removed-translation-edit/template.hbs b/webapp/app/pods/components/removed-translation-edit/template.hbs new file mode 100644 index 00000000..9db907c3 --- /dev/null +++ b/webapp/app/pods/components/removed-translation-edit/template.hbs @@ -0,0 +1,3 @@ +
    {{translation.correctedText}}
    + +{{t 'components.removed_translation_edit.cant_edit'}} diff --git a/webapp/app/pods/components/resource-pagination/component.js b/webapp/app/pods/components/resource-pagination/component.js new file mode 100644 index 00000000..7282696e --- /dev/null +++ b/webapp/app/pods/components/resource-pagination/component.js @@ -0,0 +1,26 @@ +import {or, readOnly, not} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// meta: Object containing meta infos from a model +// onSelectPage: Function +export default Component.extend({ + showPagination: or('meta.{nextPage,previousPage}'), + + hasPrevious: readOnly('meta.previousPage'), + hasNext: readOnly('meta.nextPage'), + disabledPrevious: not('hasPrevious'), + disabledNext: not('hasNext'), + + actions: { + goToNextPage() { + if (!this.meta.nextPage) return; + this.onSelectPage(this.meta.nextPage); + }, + + goToPreviousPage() { + if (!this.meta.previousPage) return; + this.onSelectPage(this.meta.previousPage); + } + } +}); diff --git a/webapp/app/pods/components/resource-pagination/styles.scss b/webapp/app/pods/components/resource-pagination/styles.scss new file mode 100644 index 00000000..b2fb09f3 --- /dev/null +++ b/webapp/app/pods/components/resource-pagination/styles.scss @@ -0,0 +1,16 @@ +& { + display: flex; + align-items: center; + justify-content: center; + margin-top: 30px; +} + +.label { + margin: 0 10px; + color: $color-grey; + font-size: 14px; +} + +.label-number { + margin: 0 4px; +} diff --git a/webapp/app/pods/components/resource-pagination/template.hbs b/webapp/app/pods/components/resource-pagination/template.hbs new file mode 100644 index 00000000..518ae1c7 --- /dev/null +++ b/webapp/app/pods/components/resource-pagination/template.hbs @@ -0,0 +1,15 @@ +{{#if showPagination}} + + + + {{attrs.meta.currentPage}} + / + {{attrs.meta.totalPages}} + + + +{{/if}} diff --git a/webapp/app/pods/components/review-progress-bar/component.js b/webapp/app/pods/components/review-progress-bar/component.js new file mode 100644 index 00000000..2d36f675 --- /dev/null +++ b/webapp/app/pods/components/review-progress-bar/component.js @@ -0,0 +1,14 @@ +import {computed} from '@ember/object'; +import Component from '@ember/component'; +import {htmlSafe} from '@ember/string'; + +// Attributes: +// correctedKeysPercentage: Number +export default Component.extend({ + progressStyles: computed('correctedKeysPercentage', function() { + let percentage = this.correctedKeysPercentage; + if (percentage < 1 && percentage !== 0) percentage = 1; + + return htmlSafe(`width: ${percentage}%`); + }) +}); diff --git a/webapp/app/pods/components/review-progress-bar/styles.scss b/webapp/app/pods/components/review-progress-bar/styles.scss new file mode 100644 index 00000000..875d6b6e --- /dev/null +++ b/webapp/app/pods/components/review-progress-bar/styles.scss @@ -0,0 +1,14 @@ +.container { + width: 100%; + height: 5px; + background: $color-white; + border: 1px solid #eee; + border-radius: 3px; + overflow: hidden; + box-shadow: 0 1px 8px rgba($color-black, 0.04); +} + +.progress { + height: 5px; + background: $color-grey; +} diff --git a/webapp/app/pods/components/review-progress-bar/template.hbs b/webapp/app/pods/components/review-progress-bar/template.hbs new file mode 100644 index 00000000..839f3a7b --- /dev/null +++ b/webapp/app/pods/components/review-progress-bar/template.hbs @@ -0,0 +1,5 @@ +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/revision-export-options/component.js b/webapp/app/pods/components/revision-export-options/component.js new file mode 100644 index 00000000..e1ffa529 --- /dev/null +++ b/webapp/app/pods/components/revision-export-options/component.js @@ -0,0 +1,109 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {gt} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// orderBy: String +// format: String +// revision: String +// document: String +// onChangeOrderBy: Function +// onChangeFormat: Function +// onChangeRevision: Function +// onChangeDocument: Function +export default Component.extend({ + i18n: service('i18n'), + globalState: service('global-state'), + + showRevisions: gt('mappedRevisions.length', 1), + showDocuments: gt('mappedDocuments.length', 1), + + orderByValue: computed('orderBy', 'orderByOptions.[]', function() { + return this.orderByOptions.find(({value}) => value === this.orderBy); + }), + + orderByOptions: computed(() => { + return [ + { + value: null, + label: 'components.revision_export_options.orders.original' + }, + {value: 'key', label: 'components.revision_export_options.orders.az'} + ]; + }), + + formatValue: computed('format', 'formatOptions', function() { + return this.formatOptions.find(({value}) => value === this.format); + }), + + formattedDocumentFormats: computed('globalState.documentFormats', function() { + if (!this.globalState.documentFormats) return []; + + return this.globalState.documentFormats.map(({slug, name}) => ({ + value: slug, + label: name + })); + }), + + formatOptions: computed('formattedDocumentFormats', function() { + return [ + { + value: null, + label: this.i18n.t('components.revision_export_options.default_format') + } + ].concat(this.formattedDocumentFormats); + }), + + revisionValue: computed('revision', 'mappedRevisions.[]', function() { + return this.mappedRevisions.find(({value}) => value === this.revision) || this.mappedRevisions[0]; + }), + + mappedRevisions: computed('revisions.[]', function() { + if (!this.revisions) return []; + + return this.revisions.map(({id, language}) => ({ + label: language.name, + value: id + })); + }), + + documentValue: computed('document', 'mappedDocuments.[]', function() { + return this.mappedDocuments.find(({value}) => value === this.document) || this.mappedDocuments[0]; + }), + + mappedDocuments: computed('documents.[]', function() { + if (!this.documents) return []; + + return this.documents.map(({id, path}) => ({ + label: path, + value: id + })); + }), + + actions: { + orderByChanged(orderBy) { + if (orderBy === this.orderBy) return; + + this.onChangeOrderBy(orderBy); + }, + + formatChanged(format) { + if (format === this.format) return; + + this.onChangeFormat(format); + }, + + documentChanged(document) { + if (document === this.document) return; + + this.onChangeDocument(document); + }, + + revisionChanged(revision) { + if (revision === this.revision) return; + + this.onChangeRevision(revision); + } + } +}); diff --git a/webapp/app/pods/components/revision-export-options/styles.scss b/webapp/app/pods/components/revision-export-options/styles.scss new file mode 100644 index 00000000..0e2a269f --- /dev/null +++ b/webapp/app/pods/components/revision-export-options/styles.scss @@ -0,0 +1,47 @@ +.subNavigation + & { + position: relative; + top: -1px; + z-index: 8; +} + +.filters { + margin-top: 0; + border-radius: 0; + font-size: 12px; +} + +.exportOptions { + display: flex; + justify-content: space-between; +} + +.exportOptions-item { + flex: 1 1 auto; + margin: 0 5px; + + .ember-power-select-trigger { + font-size: 13px; + } + + &:first-of-type { + margin-left: 0; + } + + &:last-of-type { + margin-right: 0; + } +} + +@media (max-width: (800px)) { + .exportOptions { + flex-direction: column; + } + + .exportOptions-item { + margin-bottom: 10px; + + &:last-of-type { + margin-bottom: 0; + } + } +} diff --git a/webapp/app/pods/components/revision-export-options/template.hbs b/webapp/app/pods/components/revision-export-options/template.hbs new file mode 100644 index 00000000..33a7654d --- /dev/null +++ b/webapp/app/pods/components/revision-export-options/template.hbs @@ -0,0 +1,53 @@ +
    +
    +
    + {{#if showRevisions}} +
    + {{#power-select + searchEnabled=false + selected=revisionValue + options=mappedRevisions + onchange=(action 'revisionChanged' value='value') as |option| + }} + {{option.label}} + {{/power-select}} +
    + {{/if}} + +
    + {{#power-select + searchEnabled=false + selected=orderByValue + options=orderByOptions + onchange=(action 'orderByChanged' value='value') as |option| + }} + {{t option.label}} + {{/power-select}} +
    + +
    + {{#power-select + searchEnabled=false + selected=formatValue + options=formatOptions + onchange=(action 'formatChanged' value='value') as |option| + }} + {{option.label}} + {{/power-select}} +
    + + {{#if showDocuments}} +
    + {{#power-select + searchEnabled=false + selected=documentValue + options=mappedDocuments + onchange=(action 'documentChanged' value='value') as |option| + }} + {{option.label}} + {{/power-select}} +
    + {{/if}} +
    +
    +
    diff --git a/webapp/app/pods/components/revision-selector/component.js b/webapp/app/pods/components/revision-selector/component.js new file mode 100644 index 00000000..1053ec6c --- /dev/null +++ b/webapp/app/pods/components/revision-selector/component.js @@ -0,0 +1,41 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {htmlSafe} from '@ember/string'; +import Component from '@ember/component'; + +// Attributes: +// revision: Object +// onSelect: Function +export default Component.extend({ + i18n: service(), + globalState: service('global-state'), + + hasManyRevisions: computed('revisions.[]', function() { + return this.revisions && this.revisions.length > 1; + }), + + revisionValue: computed('revision', 'revisions.[]', function() { + return this.mappedRevisions.find(({value}) => value === this.revision); + }), + + mappedRevisions: computed('revisions.[]', function() { + return this.revisions.map(({id, isMaster, language}) => { + const masterLabel = name => htmlSafe(`${name} ${this.i18n.t('components.revision_selector.master')}`); + const label = isMaster ? masterLabel(language.name) : language.name; + + return {label, value: id}; + }); + }), + + otherRevisionsCount: computed('revisions.length', function() { + return this.revisions && this.revisions.length - 1; + }), + + actions: { + selectRevision(value) { + this.set('globalState.revision', value); + + this.onSelect(value); + } + } +}); diff --git a/webapp/app/pods/components/revision-selector/styles.scss b/webapp/app/pods/components/revision-selector/styles.scss new file mode 100644 index 00000000..913935af --- /dev/null +++ b/webapp/app/pods/components/revision-selector/styles.scss @@ -0,0 +1,56 @@ +& { + position: relative; + box-shadow: 0 2px 4px rgba(#000, 0.06); +} + +.otherLanguages { + position: absolute; + bottom: 8px; + left: 13px; + font-size: 12px; + font-style: italic; + color: #ccc; + pointer-events: none; +} + +.ember-power-select-selected-item { + margin-left: 22px; + font-weight: 900; + color: $color-black; + + em { + margin-left: 1px; + font-size: 11px; + font-style: normal; + color: $color-primary; + } +} + +.ember-power-select-trigger { + padding: 10px 10px 25px; + font-size: 20px; + flex-direction: row-reverse; + border: 1px solid #eee; + + .ember-power-select-status-icon { + &:after { + content: 'Ξ'; + transform: rotate(0); + bottom: -14px; + left: 0; + color: #ddd; + } + } + + &[aria-expanded="true"] { + .ember-power-select-status-icon { + &:after { + transform: rotate(0); + } + } + } + + &:hover { + background: #fafafa; + } +} diff --git a/webapp/app/pods/components/revision-selector/template.hbs b/webapp/app/pods/components/revision-selector/template.hbs new file mode 100644 index 00000000..219c2255 --- /dev/null +++ b/webapp/app/pods/components/revision-selector/template.hbs @@ -0,0 +1,13 @@ +{{#if hasManyRevisions}} + {{#power-select + searchEnabled=false + selected=revisionValue + options=mappedRevisions + onchange=(action 'selectRevision' value='value') as |revision| + }} + {{revision.label}} + {{/power-select}} + + {{t 'components.revision_selector.languages_count' count=otherRevisionsCount}} + +{{/if}} diff --git a/webapp/app/pods/components/skeleton-ui/activities-list/styles.scss b/webapp/app/pods/components/skeleton-ui/activities-list/styles.scss new file mode 100644 index 00000000..21a22633 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/activities-list/styles.scss @@ -0,0 +1,78 @@ +& { + margin-top: 20px; +} + +.item { + position: relative; + padding-bottom: 30px; + padding-left: 38px; + margin-bottom: 30px; + border-bottom: 1px solid #eee; + + &:nth-child(2) { + opacity: 0.6; + + .item-author { + width: 120px; + } + + .item-key { + width: 150px; + } + + .item-icon { + background: rgba($color-primary, 0.3); + } + } + + &:nth-child(3) { + opacity: 0.4; + } + + &:nth-child(4) { + opacity: 0.2; + } +} + +.item-icon { + position: absolute; + left: 1px; + top: 4px; + width: 25px; + height: 25px; + border-radius: 50%; + background: #eee; +} + +.item-header { + margin-bottom: 10px; +} + +.item-key { + display: inline-block; + height: 3px; + width: 100px; + background: rgba($color-primary, 0.5); +} + +.item-author { + display: inline-block; + height: 4px; + width: 90px; + margin-right: 10px; + background: rgba(#000, 0.2); +} + +.item-text { + display: inline-block; + height: 3px; + width: 120px; + margin-right: 10px; + background: rgba(#000, 0.07); +} + +.item-content { + width: 100%; + height: 30px; + background: #fafafa; +} diff --git a/webapp/app/pods/components/skeleton-ui/activities-list/template.hbs b/webapp/app/pods/components/skeleton-ui/activities-list/template.hbs new file mode 100644 index 00000000..6357cfae --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/activities-list/template.hbs @@ -0,0 +1,39 @@ +
    +
    +
    + + + {{#if showTranslationLink}} + + {{/if}} +
    + +
    +
    +
    +
    +
    +
    + + + {{#if showTranslationLink}} + + {{/if}} +
    + +
    +
    +
    +
    +
    +
    + + + {{#if showTranslationLink}} + + {{/if}} +
    + +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/conflicts-items/styles.scss b/webapp/app/pods/components/skeleton-ui/conflicts-items/styles.scss new file mode 100644 index 00000000..2b41cade --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/conflicts-items/styles.scss @@ -0,0 +1,67 @@ +& { + margin-top: 20px; +} + +.item { + padding: 10px 7px 20px; + margin-bottom: 15px; + background: #fff; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.4; + } +} + +.item-key { + height: 5px; + width: 120px; + margin-bottom: 20px; + background: #ddd; +} + +.item-conflict { + margin-bottom: 20px; +} + +.item-conflict-previous { + height: 3px; + width: 200px; + margin-bottom: 5px; + background: #eee; +} + +.item-conflict-text-border { + height: 1px; + width: 100%; + border-top: 1px dashed #eee; + margin: 10px 0; +} + +.item-conflict-current { + height: 3px; + width: 250px; + margin-bottom: 5px; + background: rgba($color-primary, 0.1); +} + +.item-actions { + display: flex; + justify-content: space-between; +} + +.item-textarea { + height: 40px; + width: 100%; + margin-right: 20px; + border: 1px solid #fafafa; +} + +.item-button { + height: 40px; + width: 50px; + background: rgba($color-primary, 0.3); +} diff --git a/webapp/app/pods/components/skeleton-ui/conflicts-items/template.hbs b/webapp/app/pods/components/skeleton-ui/conflicts-items/template.hbs new file mode 100644 index 00000000..d4e72838 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/conflicts-items/template.hbs @@ -0,0 +1,45 @@ +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/documents-list/styles.scss b/webapp/app/pods/components/skeleton-ui/documents-list/styles.scss new file mode 100644 index 00000000..9e008a79 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/documents-list/styles.scss @@ -0,0 +1,44 @@ +& { + margin-top: 25px; +} + +.item { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #fafafa; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.2; + } +} + +.item-meta { + height: 3px; + width: 160px; + margin-bottom: 20px; + background: #fafafa; +} + +.item-document { + height: 4px; + width: 230px; + margin-bottom: 20px; + background: #eee; +} + +.item-button { + display: inline-block; + height: 30px; + width: 80px; + border: 1px solid #eee; + margin-right: 10px; +} + +.item-button--green { + border-color: rgba($color-primary, 0.3); + background: rgba($color-primary, 0.1); +} diff --git a/webapp/app/pods/components/skeleton-ui/documents-list/template.hbs b/webapp/app/pods/components/skeleton-ui/documents-list/template.hbs new file mode 100644 index 00000000..e96cd703 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/documents-list/template.hbs @@ -0,0 +1,35 @@ +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/progress-line/styles.scss b/webapp/app/pods/components/skeleton-ui/progress-line/styles.scss new file mode 100644 index 00000000..0373db6a --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/progress-line/styles.scss @@ -0,0 +1,25 @@ +&, &:before { + height: 2px; + width: 100%; + margin: 0; + border-radius: 4px; +} + +& { + display: flex; + position: relative; + bottom: 1px; + margin-bottom: -2px; +} + +&:before { + background-color: lighten($color-primary, 20%); + content: ''; + animation: progress-line-running-progress 1.8s cubic-bezier(0.5, 0, 0.1, 1) infinite; +} + +@keyframes progress-line-running-progress { + 0% { margin-left: 0; margin-right: 100%; } + 50% { margin-left: 25%; margin-right: 0%; } + 100% { margin-left: 100%; margin-right: 0; } +} diff --git a/webapp/app/pods/components/skeleton-ui/project-activities-filter/styles.scss b/webapp/app/pods/components/skeleton-ui/project-activities-filter/styles.scss new file mode 100644 index 00000000..ffe62185 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-activities-filter/styles.scss @@ -0,0 +1,3 @@ +.box { + height: 63px; +} diff --git a/webapp/app/pods/components/skeleton-ui/project-activities-filter/template.hbs b/webapp/app/pods/components/skeleton-ui/project-activities-filter/template.hbs new file mode 100644 index 00000000..bc6c920b --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-activities-filter/template.hbs @@ -0,0 +1 @@ +
    diff --git a/webapp/app/pods/components/skeleton-ui/project-comments-list/styles.scss b/webapp/app/pods/components/skeleton-ui/project-comments-list/styles.scss new file mode 100644 index 00000000..3ceae8f6 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-comments-list/styles.scss @@ -0,0 +1,66 @@ +& { + margin-top: 25px; +} + +.item { + margin-bottom: 30px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.2; + } +} + +.item-language { + height: 3px; + width: 80px; + margin-bottom: 10px; + background: #eee; +} + +.item-key { + height: 5px; + width: 180px; + margin-bottom: 15px; + background: rgba($color-primary, 0.3); +} + +.item-comments { + width: 100%; + padding: 0 10px; +} + +.item-comments-item { + padding: 10px 0; + + &:nth-child(2) { + .item-comments-item-text { + width: 270px; + } + } + + &:nth-child(3) { + .item-comments-item-text { + width: 220px; + } + } +} + +.item-comments-item-key { + height: 2px; + width: 100px; + margin-bottom: 10px; + background: rgba(#000, 0.2); +} + +.item-comments-item-text { + height: 3px; + width: 170px; + margin-bottom: 5px; + background: rgba(#000, 0.08); +} diff --git a/webapp/app/pods/components/skeleton-ui/project-comments-list/template.hbs b/webapp/app/pods/components/skeleton-ui/project-comments-list/template.hbs new file mode 100644 index 00000000..f48d8bba --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-comments-list/template.hbs @@ -0,0 +1,62 @@ +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/project-navigation/styles.scss b/webapp/app/pods/components/skeleton-ui/project-navigation/styles.scss new file mode 100644 index 00000000..f68addf6 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-navigation/styles.scss @@ -0,0 +1,37 @@ +& { + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + padding-top: 64px; + padding-right: 15px; + background: linear-gradient(65deg, rgba(#fff, 1) 60%, rgba(#000, 0.02) 100%); +} + +.item { + height: 20px; + width: 20px; + margin-bottom: 25px; + background: #eee; + border-radius: 4px; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.4; + } + + &:nth-child(4) { + opacity: 0.2; + } + + &:nth-child(5) { + opacity: 0.1; + } + + &:nth-child(6) { + opacity: 0.05; + } +} diff --git a/webapp/app/pods/components/skeleton-ui/project-navigation/template.hbs b/webapp/app/pods/components/skeleton-ui/project-navigation/template.hbs new file mode 100644 index 00000000..ab9bf14f --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/project-navigation/template.hbs @@ -0,0 +1,17 @@ +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/related-translations-list/styles.scss b/webapp/app/pods/components/skeleton-ui/related-translations-list/styles.scss new file mode 100644 index 00000000..9395fd3a --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/related-translations-list/styles.scss @@ -0,0 +1,35 @@ +& { + margin-top: 30px; +} + +.item { + padding: 0 0 15px 0; + margin-bottom: 20px; + border-bottom: 1px solid #eee; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.2; + } +} + +.item-language { + height: 4px; + width: 140px; + margin-bottom: 25px; + background: #ddd; +} + +.item-text { + height: 3px; + width: 340px; + margin-bottom: 6px; + background: #eee; + + &:last-child { + margin-bottom: 0; + } +} diff --git a/webapp/app/pods/components/skeleton-ui/related-translations-list/template.hbs b/webapp/app/pods/components/skeleton-ui/related-translations-list/template.hbs new file mode 100644 index 00000000..42352f19 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/related-translations-list/template.hbs @@ -0,0 +1,20 @@ +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/releases-list/styles.scss b/webapp/app/pods/components/skeleton-ui/releases-list/styles.scss new file mode 100644 index 00000000..9e008a79 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/releases-list/styles.scss @@ -0,0 +1,44 @@ +& { + margin-top: 25px; +} + +.item { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #fafafa; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.2; + } +} + +.item-meta { + height: 3px; + width: 160px; + margin-bottom: 20px; + background: #fafafa; +} + +.item-document { + height: 4px; + width: 230px; + margin-bottom: 20px; + background: #eee; +} + +.item-button { + display: inline-block; + height: 30px; + width: 80px; + border: 1px solid #eee; + margin-right: 10px; +} + +.item-button--green { + border-color: rgba($color-primary, 0.3); + background: rgba($color-primary, 0.1); +} diff --git a/webapp/app/pods/components/skeleton-ui/releases-list/template.hbs b/webapp/app/pods/components/skeleton-ui/releases-list/template.hbs new file mode 100644 index 00000000..e96cd703 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/releases-list/template.hbs @@ -0,0 +1,35 @@ +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/translation-comments-list/styles.scss b/webapp/app/pods/components/skeleton-ui/translation-comments-list/styles.scss new file mode 100644 index 00000000..1a03e7ee --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translation-comments-list/styles.scss @@ -0,0 +1,42 @@ +& { + margin-top: 30px; + width: 530px; +} + +.form { + height: 70px; + margin-bottom: 25px; + border: 1px solid #f3f3f3; +} + +.item { + padding: 0 0 15px 0; + margin-bottom: 20px; + border-bottom: 1px solid #eee; + + &:nth-child(3) { + opacity: 0.6; + } + + &:nth-child(4) { + opacity: 0.2; + } +} + +.item-user { + height: 4px; + width: 140px; + margin-bottom: 25px; + background: #ddd; +} + +.item-text { + height: 3px; + width: 340px; + margin-bottom: 6px; + background: #eee; + + &:last-child { + margin-bottom: 0; + } +} diff --git a/webapp/app/pods/components/skeleton-ui/translation-comments-list/template.hbs b/webapp/app/pods/components/skeleton-ui/translation-comments-list/template.hbs new file mode 100644 index 00000000..e64cbcc1 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translation-comments-list/template.hbs @@ -0,0 +1,20 @@ +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/translation-splash-title/styles.scss b/webapp/app/pods/components/skeleton-ui/translation-splash-title/styles.scss new file mode 100644 index 00000000..c1551046 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translation-splash-title/styles.scss @@ -0,0 +1,16 @@ +& { + margin: 5px 0 62px; +} + +.revision { + height: 3px; + width: 80px; + margin-bottom: 20px; + background: #eee; +} + +.translation { + height: 4px; + width: 180px; + background: rgba($color-primary, 0.2); +} diff --git a/webapp/app/pods/components/skeleton-ui/translation-splash-title/template.hbs b/webapp/app/pods/components/skeleton-ui/translation-splash-title/template.hbs new file mode 100644 index 00000000..2437ee3b --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translation-splash-title/template.hbs @@ -0,0 +1,2 @@ +
    +
    diff --git a/webapp/app/pods/components/skeleton-ui/translations-list/styles.scss b/webapp/app/pods/components/skeleton-ui/translations-list/styles.scss new file mode 100644 index 00000000..77b53500 --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translations-list/styles.scss @@ -0,0 +1,55 @@ +& { + margin-top: 20px; +} + +.item { + padding: 10px 7px 15px; + margin-bottom: 10px; + background: #fff; + + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.4; + } + + &:nth-child(4) { + opacity: 0.2; + } +} + +.item-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.item-key { + height: 5px; + width: 120px; + margin-bottom: 3px; + background: #ddd; +} + +.item-content { + margin-bottom: 5px; + height: 3px; + width: 220px; + background: #eee; + + &:nth-child(2) { + width: 340px; + } + + &:nth-child(3) { + width: 140px; + } +} + +.item-meta { + height: 4px; + width: 120px; + background: #eee; +} diff --git a/webapp/app/pods/components/skeleton-ui/translations-list/template.hbs b/webapp/app/pods/components/skeleton-ui/translations-list/template.hbs new file mode 100644 index 00000000..682abe0b --- /dev/null +++ b/webapp/app/pods/components/skeleton-ui/translations-list/template.hbs @@ -0,0 +1,35 @@ +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/webapp/app/pods/components/spin-spinner/component.js b/webapp/app/pods/components/spin-spinner/component.js new file mode 100644 index 00000000..bb8e2716 --- /dev/null +++ b/webapp/app/pods/components/spin-spinner/component.js @@ -0,0 +1,39 @@ +import {on} from '@ember/object/evented'; +import Component from '@ember/component'; + +const defaultConfig = { + color: '#333', + corners: 1, + direction: 1, + fps: 20, + length: 7, + lines: 12, + opacity: 0.25, + radius: 10, + rotate: 0, + scale: 1.0, + shadow: false, + speed: 1, + top: '0', + left: '0', + trail: 100, + width: 5, + zIndex: 2000, + spinner: null, + hwaccel: true +}; + +export default Component.extend({ + ...defaultConfig, + lookupUpConfig: on('willInsertElement', function() { + this.spinnerArgs = this.getProperties(Object.keys(defaultConfig)); + }), + + didInsertElement() { + this.spinner = new Spinner(this.spinnerArgs).spin(this.element); + }, + + willRemoveElement() { + this.spinner.stop(); + } +}); diff --git a/webapp/app/pods/components/spin-spinner/styles.scss b/webapp/app/pods/components/spin-spinner/styles.scss new file mode 100644 index 00000000..a1333024 --- /dev/null +++ b/webapp/app/pods/components/spin-spinner/styles.scss @@ -0,0 +1,4 @@ +& { + position: relative; + top: -40px; +} diff --git a/webapp/app/pods/components/time-ago-in-words-tag/component.js b/webapp/app/pods/components/time-ago-in-words-tag/component.js new file mode 100644 index 00000000..0a68b001 --- /dev/null +++ b/webapp/app/pods/components/time-ago-in-words-tag/component.js @@ -0,0 +1,26 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import Component from '@ember/component'; +import dateFormat from 'npm:date-fns/format'; + +// Attributes: +// date: String +export default Component.extend({ + i18n: service(), + + attributeBindings: ['formattedDatetime:datetime', 'humanizedDate:title'], + + tagName: 'time', + + // The follow property returns a formatted date like this: 2016-02-03T11:02:34 + formattedDatetime: computed('date', function() { + const format = this.i18n.t('components.time_ago_in_words_tag.formatted_date_time_format').toString(); + return dateFormat(new Date(this.date), format); // Ex.: 2016-02-03T11:02:34 + }), + + // The follow property returns a formatted date like this: Wednesday, February 2 2016, 11:02 am + humanizedDate: computed('date', function() { + const format = this.i18n.t('components.time_ago_in_words_tag.humanized_date_title_format').toString(); + return dateFormat(new Date(this.date), format); // Ex.: 2016-02-03T11:02:34 + }) +}); diff --git a/webapp/app/pods/components/time-ago-in-words-tag/template.hbs b/webapp/app/pods/components/time-ago-in-words-tag/template.hbs new file mode 100644 index 00000000..48411327 --- /dev/null +++ b/webapp/app/pods/components/time-ago-in-words-tag/template.hbs @@ -0,0 +1 @@ +{{time-ago-in-words attrs.date}} diff --git a/webapp/app/pods/components/translation-activities-list/component.js b/webapp/app/pods/components/translation-activities-list/component.js new file mode 100644 index 00000000..d4c20686 --- /dev/null +++ b/webapp/app/pods/components/translation-activities-list/component.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// activities: Array of object +// permissions: Ember Object containing +export default Component.extend(); diff --git a/webapp/app/pods/components/translation-activities-list/styles.scss b/webapp/app/pods/components/translation-activities-list/styles.scss new file mode 100644 index 00000000..a45934d6 --- /dev/null +++ b/webapp/app/pods/components/translation-activities-list/styles.scss @@ -0,0 +1,16 @@ +.list { + position: relative; + margin-top: 30px; + + &:before { + display: block; + position: absolute; + content: ''; + width: 1px; + height: 100%; + top: 0; + left: 9px; + z-index: 8; + background: #eee; + } +} diff --git a/webapp/app/pods/components/translation-activities-list/template.hbs b/webapp/app/pods/components/translation-activities-list/template.hbs new file mode 100644 index 00000000..a12ff179 --- /dev/null +++ b/webapp/app/pods/components/translation-activities-list/template.hbs @@ -0,0 +1,11 @@ +
      + {{#each activities key='id' as |activity|}} + {{activity-item + project=project + permissions=permissions + showTranslationLink=false + componentTranslationPrefix='translation_activities_list_item' + activity=activity + }} + {{/each}} +
    diff --git a/webapp/app/pods/components/translation-comment-form/component.js b/webapp/app/pods/components/translation-comment-form/component.js new file mode 100644 index 00000000..8266ff19 --- /dev/null +++ b/webapp/app/pods/components/translation-comment-form/component.js @@ -0,0 +1,34 @@ +import Component from '@ember/component'; + +// Attributes: +// onSubmit: Function +export default Component.extend({ + text: null, + loading: false, + error: false, + + actions: { + submit() { + this._onLoading(); + + this.onSubmit(this.text) + .then(this._onSuccess.bind(this)) + .catch(this._onError.bind(this)); + } + }, + + _onLoading() { + this.set('error', false); + this.set('loading', true); + }, + + _onError() { + this.set('loading', false); + this.set('error', true); + }, + + _onSuccess() { + this.set('loading', false); + this.set('text', null); + } +}); diff --git a/webapp/app/pods/components/translation-comment-form/styles.scss b/webapp/app/pods/components/translation-comment-form/styles.scss new file mode 100644 index 00000000..3339b654 --- /dev/null +++ b/webapp/app/pods/components/translation-comment-form/styles.scss @@ -0,0 +1,40 @@ +& { + margin-top: 30px; +} + +.form-content { + display: flex; + align-items: flex-start; + + .label { + padding: 10px 12px; + } +} + +.error { + display: block; + margin-bottom: 10px; + color: $color-error; + font-size: 13px; + font-weight: bold; +} + +.inputText { + @extend %textInput; + flex-grow: 1; + padding: 10px; + margin-right: 10px; + width: 100%; + font-family: $font-primary; + font-size: 13px; +} + +@media (max-width: ($screen-sm)) { + .form-content { + flex-direction: column; + } + + .inputText { + margin-bottom: 10px; + } +} diff --git a/webapp/app/pods/components/translation-comment-form/template.hbs b/webapp/app/pods/components/translation-comment-form/template.hbs new file mode 100644 index 00000000..0bc47f1e --- /dev/null +++ b/webapp/app/pods/components/translation-comment-form/template.hbs @@ -0,0 +1,24 @@ +
    + {{#if error}} + + {{t 'components.translation_comment_form.submit_error'}} + + {{/if}} + +
    + {{quick-submit-textarea + onSubmit=(action 'submit') + rows=3 + value=text + class='inputText' + placeholder=(t 'components.translation_comment_form.comment_placeholder') + }} + + {{#async-button + loading=loading + class='button button--filled' + }} + {{t 'components.translation_comment_form.comment_button'}} + {{/async-button}} +
    +
    diff --git a/webapp/app/pods/components/translation-comments-list/component.js b/webapp/app/pods/components/translation-comments-list/component.js new file mode 100644 index 00000000..c4555c7e --- /dev/null +++ b/webapp/app/pods/components/translation-comments-list/component.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +// Attributes: +// comments: Array of object +export default Component.extend(); diff --git a/webapp/app/pods/components/translation-comments-list/styles.scss b/webapp/app/pods/components/translation-comments-list/styles.scss new file mode 100644 index 00000000..d9de738d --- /dev/null +++ b/webapp/app/pods/components/translation-comments-list/styles.scss @@ -0,0 +1,93 @@ +& { + position: relative; + box-shadow: 0 1px 5px rgba($color-black, 0.04); + border: 1px solid $color-border; + + &:before, + &:after { + position: absolute; + top: -16px; + left: 9px; + content: ' '; + width: 1px; + height: 1px; + border: 8px solid transparent; + } + + &:before { + top: -17px; + border-bottom-color: darken($color-border, 4%); + } + + &:after { + border-bottom-color: #fff; + } +} + +&.translationRemoved { + opacity: 0.5; + + .itemComment { + padding: 4px 8px 8px; + } +} + +&.at-translation { + margin-top: 20px; + + &:before, + &:after { + display: none; + } +} + +.itemComment { + padding: 8px 10px 10px; + border-bottom: 1px solid #eee; + + &:focus, + &:hover { + background: #fafafa; + } + + &:last-of-type { + border-bottom: 0; + } +} + +.itemComment-header { + display: flex; + align-items: center; +} + +.itemComment-user { + display: inline-flex; + align-items: center; + margin-right: 6px; + font-size: 12px; + font-weight: bold; +} + +.itemComment-user-picture { + width: 16px; + height: 16px; + margin-right: 6px; + border-radius: 3px; +} + +.itemComment-date { + color: darken($color-grey, 15%); + font-size: 11px; +} + +.itemComment-content { + margin-top: 5px; + font-size: 13px; +} + +@media (max-width: ($screen-lg + 40px)) { + & { + border-color: transparent; + box-shadow: none; + } +} diff --git a/webapp/app/pods/components/translation-comments-list/template.hbs b/webapp/app/pods/components/translation-comments-list/template.hbs new file mode 100644 index 00000000..f6f0f3c9 --- /dev/null +++ b/webapp/app/pods/components/translation-comments-list/template.hbs @@ -0,0 +1,27 @@ +
      + {{#each comments key='id' as |comment|}} +
    • +
      + + {{#if comment.user.pictureUrl}} + + {{/if}} + + {{comment.user.fullname}} + + + {{time-ago-in-words-tag + date=comment.insertedAt + class='itemComment-date' + }} +
      + +
      {{comment.text}}
      +
    • + {{else}} + {{empty-content + iconPath='assets/empty.svg' + text=(t 'components.translation_comments_list.no_comments') + }} + {{/each}} +
    diff --git a/webapp/app/pods/components/translation-comments-subscriptions/component.js b/webapp/app/pods/components/translation-comments-subscriptions/component.js new file mode 100644 index 00000000..8443fa89 --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/component.js @@ -0,0 +1,15 @@ +import {computed} from '@ember/object'; +import Component from '@ember/component'; + +// Attributes +// subscriptions: Array of +// collaborators: Array of +// onCreateSubscription: Function +// onDeleteSubscription: Function +export default Component.extend({ + tagName: 'ul', + + filteredCollaborators: computed('collaborators', function() { + return this.collaborators.filter(collaborator => !collaborator.isPending).filter(collaborator => collaborator.role !== 'BOT'); + }) +}); diff --git a/webapp/app/pods/components/translation-comments-subscriptions/item/component.js b/webapp/app/pods/components/translation-comments-subscriptions/item/component.js new file mode 100644 index 00000000..767fa12d --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/item/component.js @@ -0,0 +1,35 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads, bool} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// collaborator: Object +// subscriptions: Array of +// onCreateSubscription: Function +// onDeleteSubscription: Function +export default Component.extend({ + classNameBindings: ['isCurrentUser:currentUser'], + tagName: 'li', + + session: service('session'), + currentUser: reads('session.credentials.user'), + + isCurrentUser: computed('currentUser.id', 'collaborator.user.id', function() { + return this.currentUser.id === this.collaborator.user.id; + }), + + isSubscribed: bool('subscription'), + + subscription: computed('subscriptions.[]', 'collaborator.user.id', function() { + return this.subscriptions.find(subscription => subscription.user.id === this.collaborator.user.id); + }), + + click() { + if (this.isSubscribed) { + this.onDeleteSubscription(this.subscription); + } else { + this.onCreateSubscription(this.collaborator.user); + } + } +}); diff --git a/webapp/app/pods/components/translation-comments-subscriptions/item/styles.scss b/webapp/app/pods/components/translation-comments-subscriptions/item/styles.scss new file mode 100644 index 00000000..86413f69 --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/item/styles.scss @@ -0,0 +1,45 @@ +& { + display: flex; + align-items: flex-start; + margin: 0 0 10px; + font-size: 13px; + color: #888; + cursor: pointer; + transition: $transition-speed $transition-easing; + transition-property: color; + + &:last-child { + margin: 0; + } + + &:hover { + color: #222; + + .user-email { + color: #333; + } + } +} + +&.currentUser { + font-weight: bold; + color: #444; +} + +.user { + margin-left: 7px; +} + +.user-email { + font-size: 12px; + font-weight: normal; + font-style: italic; + color: #777; +} + +.checkbox { + display: block; + flex-shrink: 0; + margin-top: 3px; + cursor: pointer; +} diff --git a/webapp/app/pods/components/translation-comments-subscriptions/item/template.hbs b/webapp/app/pods/components/translation-comments-subscriptions/item/template.hbs new file mode 100644 index 00000000..8533dd84 --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/item/template.hbs @@ -0,0 +1,10 @@ +{{input + type='checkbox' + class='checkbox' + checked=isSubscribed +}} + + + {{collaborator.user.fullname}} + {{collaborator.email}} + diff --git a/webapp/app/pods/components/translation-comments-subscriptions/styles.scss b/webapp/app/pods/components/translation-comments-subscriptions/styles.scss new file mode 100644 index 00000000..73a355a0 --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/styles.scss @@ -0,0 +1,17 @@ +& { + background: $color-white; + padding: 10px; + border-radius: 3px; + border: 2px solid #eee; +} + +.title { + display: block; + padding: 0 0 5px; + margin: 0 0 10px; + font-size: 12px; + font-weight: normal; + font-style: normal; + color: $color-primary; + border-bottom: 1px solid #eee; +} diff --git a/webapp/app/pods/components/translation-comments-subscriptions/template.hbs b/webapp/app/pods/components/translation-comments-subscriptions/template.hbs new file mode 100644 index 00000000..538e2b3e --- /dev/null +++ b/webapp/app/pods/components/translation-comments-subscriptions/template.hbs @@ -0,0 +1,9 @@ +{{t 'components.translation_comments_subscriptions.title'}} +{{#each filteredCollaborators key='id' as |collaborator|}} + {{translation-comments-subscriptions/item + subscriptions=subscriptions + collaborator=collaborator + onCreateSubscription=onCreateSubscription + onDeleteSubscription=onDeleteSubscription + }} +{{/each}} diff --git a/webapp/app/pods/components/translation-conversation/component.js b/webapp/app/pods/components/translation-conversation/component.js new file mode 100644 index 00000000..6a2326f4 --- /dev/null +++ b/webapp/app/pods/components/translation-conversation/component.js @@ -0,0 +1,13 @@ +import Component from '@ember/component'; + +// Attributes +// permissions: Ember Object containing +// translation: Object +// comments: Array of +// collaborators: Array of +// subscriptions: Array of +// onCreateSubscription: Function +// onDeleteSubscription: Function +// onSelectPage: Function +// onSubmit: Function +export default Component.extend(); diff --git a/webapp/app/pods/components/translation-conversation/styles.scss b/webapp/app/pods/components/translation-conversation/styles.scss new file mode 100644 index 00000000..375baa79 --- /dev/null +++ b/webapp/app/pods/components/translation-conversation/styles.scss @@ -0,0 +1,14 @@ +& { + display: flex; + width: 100%; +} + +.comments { + flex: 1 1 100%; +} + +.subscriptions { + flex: 1 1 100%; + max-width: 250px; + margin: 30px 0 0 25px; +} diff --git a/webapp/app/pods/components/translation-conversation/template.hbs b/webapp/app/pods/components/translation-conversation/template.hbs new file mode 100644 index 00000000..3c0fa4b3 --- /dev/null +++ b/webapp/app/pods/components/translation-conversation/template.hbs @@ -0,0 +1,26 @@ +
    + {{#if (get permissions 'create_comment')}} + {{#unless translation.isRemoved}} + {{translation-comment-form onSubmit=onSubmit}} + {{/unless}} + {{/if}} + + {{translation-comments-list + comments=comments.entries + class='at-translation' + }} + + {{resource-pagination + meta=comments.meta + onSelectPage=onSelectPage + }} +
    + +
    + {{translation-comments-subscriptions + collaborators=collaborators + subscriptions=subscriptions + onCreateSubscription=onCreateSubscription + onDeleteSubscription=onDeleteSubscription + }} +
    diff --git a/webapp/app/pods/components/translation-edit/component.js b/webapp/app/pods/components/translation-edit/component.js new file mode 100644 index 00000000..7a4761d4 --- /dev/null +++ b/webapp/app/pods/components/translation-edit/component.js @@ -0,0 +1,46 @@ +import {computed} from '@ember/object'; +import {reads} from '@ember/object/computed'; +import Component from '@ember/component'; + +// Attributes: +// translation: Object +// permissions: Ember Object containing +// onUpdateText: Function +// onCorrectConflict: Function +// onUncorrectConflict: Function +export default Component.extend({ + isCorrectingConflict: false, + isUncorrectingConflict: false, + isUpdatingText: false, + + text: reads('translation.correctedText'), + samePreviousText: computed('translation.{conflictedText,correctedText}', function() { + return this.translation.conflictedText === this.translation.correctedText; + }), + + hasTextNotChanged: computed('text', 'translation.correctedText', function() { + if (!this.translation) return false; + + return this.text === this.translation.correctedText; + }), + + actions: { + correctConflict() { + this.set('isCorrectingConflict', true); + + this.onCorrectConflict(this.text).then(() => this.set('isCorrectingConflict', false)); + }, + + uncorrectConflict() { + this.set('isUncorrectingConflict', true); + + this.onUncorrectConflict().then(() => this.set('isUncorrectingConflict', false)); + }, + + updateText() { + this.set('isUpdatingText', true); + + this.onUpdateText(this.text).then(() => this.set('isUpdatingText', false)); + } + } +}); diff --git a/webapp/app/pods/components/translation-edit/form/component.js b/webapp/app/pods/components/translation-edit/form/component.js new file mode 100644 index 00000000..7208fedc --- /dev/null +++ b/webapp/app/pods/components/translation-edit/form/component.js @@ -0,0 +1,13 @@ +import {equal} from '@ember/object/computed'; +import Component from '@ember/component'; + +export default Component.extend({ + rows: 10, + showTypeHints: true, + + isStringType: equal('valueType', 'STRING'), + isBooleanType: equal('valueType', 'BOOLEAN'), + isIntegerType: equal('valueType', 'INTEGER'), + isEmptyType: equal('valueType', 'EMPTY'), + isNullType: equal('valueType', 'NULL') +}); diff --git a/webapp/app/pods/components/translation-edit/form/styles.scss b/webapp/app/pods/components/translation-edit/form/styles.scss new file mode 100644 index 00000000..905f41f1 --- /dev/null +++ b/webapp/app/pods/components/translation-edit/form/styles.scss @@ -0,0 +1,41 @@ +& { + height: 100%; +} + +.ember-radio-button { + display: flex; + align-items: center; + padding: 10px; + margin-bottom: 3px; + border-radius: 3px; + font-weight: bold; + font-size: 13px; + color: #444; + + &.checked { + background: lighten($color-primary, 45%); + color: darken($color-primary, 25%); + } + + input { + margin-right: 8px; + } +} + +.label { + width: 100%; + padding: 5px 5px 5px 8px; + border-left: 2px solid #eee; + margin-bottom: 5px; + background: #fafafa; + font-size: 12px; + color: #888; +} + +.inputText { + @extend %textInput; + width: 100%; + height: 100%; + padding: 10px; + font-size: 12px; +} diff --git a/webapp/app/pods/components/translation-edit/form/template.hbs b/webapp/app/pods/components/translation-edit/form/template.hbs new file mode 100644 index 00000000..095d0f72 --- /dev/null +++ b/webapp/app/pods/components/translation-edit/form/template.hbs @@ -0,0 +1,49 @@ +{{#if isBooleanType}} + + The text should be either true or false + + + {{#radio-button + groupValue=value + value='true' + }} + {{t 'components.translation_edit.form.true_option'}} + {{/radio-button}} + + {{#radio-button + groupValue=value + value='false' + }} + {{t 'components.translation_edit.form.false_option'}} + {{/radio-button}} +{{else}} + {{#if showTypeHints}} + {{#if isIntegerType}} + + {{t 'components.translation_edit.form.integer_type_notice'}} + + {{/if}} + + {{#if isEmptyType}} + + {{t 'components.translation_edit.form.empty_type_notice'}} + + {{/if}} + + {{#if isNullType}} + + {{t 'components.translation_edit.form.null_type_notice'}} + + {{/if}} + {{/if}} + + {{quick-submit-textarea + rows=rows + disabled=disabled + value=value + class='inputText' + onFocus=onFocus + onBlur=onBlur + onSubmit=onSubmit + }} +{{/if}} diff --git a/webapp/app/pods/components/translation-edit/styles.scss b/webapp/app/pods/components/translation-edit/styles.scss new file mode 100644 index 00000000..5a45cdee --- /dev/null +++ b/webapp/app/pods/components/translation-edit/styles.scss @@ -0,0 +1,77 @@ +& { + margin-top: 30px; +} + +.previousText { + margin-bottom: 15px; + color: $color-grey; + font-size: 12px; +} + +.previousText-label { + color: darken($color-grey, 10%); + font-weight: bold; +} + +.previousText-empty { + font-style: italic; +} + +.previousText-text { + white-space: pre-wrap; + font-size: 11px; +} + +.actions { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: 15px; +} + +.actions-link { + display: flex; + align-items: center; + font-size: 13px; + font-weight: bold; + text-decoration: none; + color: $color-primary; + transition: $transition-speed $transition-easing; + transition-property: color; + + &:focus, + &:hover { + color: darken($color-primary, 15%); + + .actions-link-icon { + fill: darken($color-primary, 15%); + } + } +} + +.actions-link-icon { + width: 18px; + height: 18px; + margin-right: 3px; + fill: $color-primary; + transition: $transition-speed $transition-easing; + transition-property: fill; +} + +.actions-buttons { + display: flex; + justify-content: flex-end; + align-items: center; + + & > .button { + margin-left: 10px; + } +} + +.actions-updatedAt { + margin-right: 10px; + color: $color-grey; + font-size: 11px; + font-style: italic; +} diff --git a/webapp/app/pods/components/translation-edit/template.hbs b/webapp/app/pods/components/translation-edit/template.hbs new file mode 100644 index 00000000..06804974 --- /dev/null +++ b/webapp/app/pods/components/translation-edit/template.hbs @@ -0,0 +1,124 @@ +
    + {{#if translation.isConflicted}} + + {{#if translation.conflictedText}} + {{#unless samePreviousText}} +
    + {{t 'components.translation_edit.previous_text'}} +
    {{translation.conflictedText}}
    +
    + {{/unless}} + {{/if}} + + {{translation-edit/form + disabled=translation.isRemoved + valueType=translation.valueType + value=text + onSubmit=(action 'updateText') + }} + + {{#unless translation.isRemoved}} +
    + + +
    +
    + {{t 'components.translation_edit.last_updated_label'}} + {{time-ago-in-words-tag date=translation.updatedAt}} +
    + + {{#if (get permissions 'update_translation')}} + {{#async-button + loading=isUpdatingText + disabled=hasTextNotChanged + class='button button--filled button--white' + onClick=(action 'updateText') + }} + {{t 'components.translation_edit.update_text'}} + {{/async-button}} + {{/if}} + + {{#if (get permissions 'correct_translation')}} + {{#async-button + loading=isCorrectingConflict + class='button button--filled' + onClick=(action 'correctConflict') + }} + {{inline-svg '/assets/check.svg' class='button-icon'}} + {{t 'components.translation_edit.correct_button'}} + {{/async-button}} + {{/if}} +
    +
    + {{/unless}} + + {{else}} + {{translation-edit/form + disabled=translation.isRemoved + valueType=translation.valueType + value=text + onSubmit=(action 'updateText') + }} + + {{#unless translation.isRemoved}} +
    + + +
    +
    + {{t 'components.translation_edit.last_updated_label'}} + {{time-ago-in-words-tag date=translation.updatedAt}} +
    + + {{#if (get permissions 'update_translation')}} + {{#async-button + loading=isUpdatingText + disabled=hasTextNotChanged + class='button button--filled button--white' + onClick=(action 'updateText') + }} + {{t 'components.translation_edit.update_text'}} + {{/async-button}} + {{/if}} + + {{#unless translation.version}} + {{#if (get permissions 'uncorrect_translation')}} + {{#async-button + loading=isUncorrectingConflict + class='button button--filled button--red' + onClick=(action 'uncorrectConflict') + }} + {{inline-svg '/assets/revert.svg' class='button-icon'}} + {{t 'components.translation_edit.uncorrect_button'}} + {{/async-button}} + {{/if}} + {{/unless}} +
    +
    + {{/unless}} + {{/if}} +
    diff --git a/webapp/app/pods/components/translation-navigation/component.js b/webapp/app/pods/components/translation-navigation/component.js new file mode 100644 index 00000000..d16551ea --- /dev/null +++ b/webapp/app/pods/components/translation-navigation/component.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// translation: Object +export default Component.extend(); diff --git a/webapp/app/pods/components/translation-navigation/template.hbs b/webapp/app/pods/components/translation-navigation/template.hbs new file mode 100644 index 00000000..92d59e99 --- /dev/null +++ b/webapp/app/pods/components/translation-navigation/template.hbs @@ -0,0 +1,61 @@ + diff --git a/webapp/app/pods/components/translation-splash-title/component.js b/webapp/app/pods/components/translation-splash-title/component.js new file mode 100644 index 00000000..a71a36f9 --- /dev/null +++ b/webapp/app/pods/components/translation-splash-title/component.js @@ -0,0 +1,15 @@ +import Component from '@ember/component'; +import {computed} from '@ember/object'; + +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +// Attributes: +// project: Object +// translation: Object +export default Component.extend({ + translationKey: parsedKeyProperty('translation.key'), + + versionParam: computed('translation.version.id', function() { + return this.getWithDefault('translation.version.id', null); + }) +}); diff --git a/webapp/app/pods/components/translation-splash-title/styles.scss b/webapp/app/pods/components/translation-splash-title/styles.scss new file mode 100644 index 00000000..4177b394 --- /dev/null +++ b/webapp/app/pods/components/translation-splash-title/styles.scss @@ -0,0 +1,93 @@ +& { + margin-bottom: 40px; +} + +.language { + display: block; + margin-bottom: 6px; + color: lighten($color-black, 20%); + font-size: 14px; + text-decoration: none; + transition: $transition-speed $transition-easing; + transition-property: color; + + &:focus, + &:hover { + color: $color-black; + + .back-icon { + fill: $color-black; + transform: translateX(-2px); + } + } +} + +.back-icon { + width: 11px; + height: 11px; + fill: lighten($color-black, 20%); + transition: $transition-speed $transition-easing; + transition-property: fill transform; +} + +.key { + @extend %translationKeyBase; + margin-bottom: 3px; + font-weight: bold; + font-size: 22px; + color: $color-primary; + line-height: 1.3; +} + +.key-prefix { + display: block; + font-weight: 300; + font-size: 14px; + color: #c8c8c8; +} + +.version-tag { + position: relative; + padding-left: 17px; + text-transform: none; + font-family: $font-monospace; + font-weight: normal; + color: #888; +} + +.version-icon { + position: absolute; + top: -1px; + left: 0; + width: 15px; + height: 15px; + fill: #888; +} + +.removedBadge { + display: block; + margin-top: 10px; + font-size: 12px; + color: $color-error; +} + +.conflictedBadge { + transition: $transition-speed $transition-easing; + transition-property: border-color, color, background; + display: inline-block; + padding: 1px 6px 1px 5px; + background: $color-primary; + border-radius: 3px; + color: $color-white; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + text-decoration: none; + + &:focus, + &:hover { + color: darken($color-primary, 10%); + background: lighten($color-primary, 38%); + border-color: lighten($color-primary, 20%); + } +} diff --git a/webapp/app/pods/components/translation-splash-title/template.hbs b/webapp/app/pods/components/translation-splash-title/template.hbs new file mode 100644 index 00000000..ad816a19 --- /dev/null +++ b/webapp/app/pods/components/translation-splash-title/template.hbs @@ -0,0 +1,46 @@ +
    + {{#link-to + 'logged-in.project.revision.translations' + project.id + translation.revision.id + (query-params version=versionParam) + class='language' + }} + {{inline-svg 'assets/chevron-left.svg' class='back-icon'}} + {{translation.revision.language.name}} + {{/link-to}} + +

    + {{translationKey.prefix}} + {{translationKey.value}} +

    + + {{#if translation.version}} + {{#acc-badge}} + + {{inline-svg 'assets/tag.svg' class='version-icon'}} + {{translation.version.tag}} + + {{/acc-badge}} + {{/if}} + + {{#if translation.isRemoved}} +
    + {{t 'components.translation_splash_title.removed_label' removedAt=(time-ago-in-words translation.updatedAt)}} +
    + {{else if translation.isConflicted}} + {{#acc-badge + link=true + primary=true + }} + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + translation.revision.id + (query-params query=translation.id) + }} + {{t 'components.translation_splash_title.conflicted_label'}} + {{/link-to}} + {{/acc-badge}} + {{/if}} +
    diff --git a/webapp/app/pods/components/translations-filter/component.js b/webapp/app/pods/components/translations-filter/component.js new file mode 100644 index 00000000..6ed187b7 --- /dev/null +++ b/webapp/app/pods/components/translations-filter/component.js @@ -0,0 +1,74 @@ +import {computed, observer} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads, gt} from '@ember/object/computed'; +import Component from '@ember/component'; +import {run} from '@ember/runloop'; + +const DEBOUNCE_OFFSET = 500; // ms + +// Attributes: +// query: String +// document: Object +// documents: Array of +// version: Object +// versions: Array of +// onChangeQuery: Function +// onChangeDocument: Function +// onChangeVersion: Function +export default Component.extend({ + i18n: service(), + + debouncedQuery: reads('query'), + showDocumentsSelect: gt('documents.length', 1), + showVersionsSelect: gt('versions.length', 0), + + mappedDocuments: computed('documents.[]', function() { + const documents = this.documents.map(({id, path}) => ({ + label: path, + value: id + })); + + documents.unshift({ + label: this.i18n.t('components.translations_filter.document_default_option_text'), + value: null + }); + + return documents; + }), + + documentValue: computed('document', 'mappedDocuments.[]', function() { + return this.mappedDocuments.find(({value}) => value === this.document); + }), + + mappedVersions: computed('versions.[]', function() { + const versions = this.versions.map(({id, tag}) => ({ + label: tag, + value: id + })); + + versions.unshift({ + label: this.i18n.t('components.translations_filter.version_default_option_text'), + value: null + }); + + return versions; + }), + + versionValue: computed('version', 'mappedVersions.[]', function() { + return this.mappedVersions.find(({value}) => value === this.version); + }), + + queryDidChanges: observer('debouncedQuery', function() { + run.debounce(this, this._debounceQuery, DEBOUNCE_OFFSET); + }), + + _debounceQuery() { + this.onChangeQuery(this.debouncedQuery); + }, + + actions: { + submitForm() { + this._debounceQuery(); + } + } +}); diff --git a/webapp/app/pods/components/translations-filter/styles.scss b/webapp/app/pods/components/translations-filter/styles.scss new file mode 100644 index 00000000..e863db82 --- /dev/null +++ b/webapp/app/pods/components/translations-filter/styles.scss @@ -0,0 +1,54 @@ +.subNavigation + & { + position: relative; + top: -1px; + z-index: 8; +} + +.filters-wrapper { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.filters-content { + flex-grow: 1; + margin-right: 15px; +} + +.queryForm { + position: relative; + margin-right: 15px; +} + +.search-icon { + position: absolute; + top: 50%; + margin-top: -10px; + left: 7px; + width: 20px; + height: 20px; + fill: #bbb; +} + +.input { + @extend %textInput; + width: 100%; + padding: 7px 7px 7px 30px; + font-family: $font-primary; + font-size: 14px; + color: $color-black; + + &:focus { + box-shadow: inset 0 1px 2px #f6f6f6, 0 1px 2px rgba($color-black, 0.06); + } + + &::placeholder { + color: $color-grey; + } +} + +.totalEntries { + margin-top: 10px; + color: $color-grey; + font-size: 12px; +} diff --git a/webapp/app/pods/components/translations-filter/template.hbs b/webapp/app/pods/components/translations-filter/template.hbs new file mode 100644 index 00000000..714dbe3d --- /dev/null +++ b/webapp/app/pods/components/translations-filter/template.hbs @@ -0,0 +1,52 @@ +
    +
    +
    +
    + {{inline-svg '/assets/search.svg' class='search-icon'}} + + {{input + class='input' + type='text' + placeholder=(t 'components.translations_filter.input_placeholder_text') + value=debouncedQuery + }} +
    + +
    + {{#if showDocumentsSelect}} +
    +
    + {{#power-select + searchEnabled=false + selected=documentValue + options=mappedDocuments + onchange=(action onChangeDocument value='value') as |document| + }} + {{document.label}} + {{/power-select}} +
    +
    + {{/if}} + + {{#if showVersionsSelect}} +
    +
    + {{#power-select + searchEnabled=false + selected=versionValue + options=mappedVersions + onchange=(action onChangeVersion value='value') as |version| + }} + {{version.label}} + {{/power-select}} +
    +
    + {{/if}} +
    +
    + + {{#if meta.totalEntries}} + {{t 'components.translations_filter.total_entries_count' count=meta.totalEntries}} + {{/if}} +
    +
    diff --git a/webapp/app/pods/components/translations-list/component.js b/webapp/app/pods/components/translations-list/component.js new file mode 100644 index 00000000..fdb96847 --- /dev/null +++ b/webapp/app/pods/components/translations-list/component.js @@ -0,0 +1,9 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// revisionId: ID +// translations: Array of object +// query: String +// onUpdateText: Function +export default Component.extend(); diff --git a/webapp/app/pods/components/translations-list/item/component.js b/webapp/app/pods/components/translations-list/item/component.js new file mode 100644 index 00000000..c076c497 --- /dev/null +++ b/webapp/app/pods/components/translations-list/item/component.js @@ -0,0 +1,45 @@ +import {reads, equal} from '@ember/object/computed'; +import Component from '@ember/component'; +import {run} from '@ember/runloop'; + +import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key'; + +// Attributes: +// project: Object +// revisionId: ID +// translation: Object +// onUpdateText: Function +export default Component.extend({ + classNameBindings: ['isInEditMode:item--editMode'], + tag: 'li', + + isSaving: false, + isInEditMode: false, + editText: reads('translation.correctedText'), + isTextEmpty: equal('translation.valueType', 'EMPTY'), + + translationKey: parsedKeyProperty('translation.key'), + + actions: { + save() { + this.set('isSaving', true); + + this.onUpdateText(this.translation, this.editText).then(() => { + this.set('isSaving', false); + this.toggleProperty('isInEditMode'); + }); + }, + + toggleEdit() { + this.set('editText', this.translation.correctedText); + this.toggleProperty('isInEditMode'); + + if (this.isInEditMode) { + run.next(this, function() { + const input = this.element.querySelector('textarea'); + if (input) input.focus(); + }); + } + } + } +}); diff --git a/webapp/app/pods/components/translations-list/item/styles.scss b/webapp/app/pods/components/translations-list/item/styles.scss new file mode 100644 index 00000000..6d6475d5 --- /dev/null +++ b/webapp/app/pods/components/translations-list/item/styles.scss @@ -0,0 +1,155 @@ +& { + transition: $transition-speed $transition-easing; + transition-property: background; + display: block; + margin: 8px 0; + padding: 10px; + border: 1px solid transparent; + border-radius: 3px; + + &:focus, + &:hover { + background: $color-light-grey; + + .item-edit { + transform: translateX(-40px); + opacity: 1; + } + } + + &.item--editMode { + background: $color-light-grey; + border: 1px solid lighten($color-grey, 25%); + + .item-edit { + transform: translateX(-40px); + opacity: 1; + } + + .item-edit-icon { + &:focus, + &:hover { + fill: $color-error; + } + } + } +} + +.item-link { + text-decoration: none; + + &:focus, + &:hover { + .item-key { + color: $color-primary; + } + } +} + +.item-header { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.item-edit { + position: absolute; + opacity: 0; + top: 0; + padding: 2px 8px; + transform: translateX(-20px); + transition: 0.2s ease-in-out; + transition-property: opacity, transform; +} + +.item-edit-icon { + width: 18px; + height: 18px; + cursor: pointer; + fill: $color-grey; + + &:focus, + &:hover { + fill: $color-primary; + } +} + +.item-key-prefix { + display: block; + font-size: 11px; + color: #959595; + font-weight: 300; +} + +.item-key { + display: block; + @extend %translationKeyBase; + transition: $transition-speed $transition-easing; + transition-property: color; + margin-right: 15px; + line-height: 1.5; + font-size: 12px; + font-weight: bold; +} + +.item-text { + @extend %translationTextBase; + display: block; + width: 100%; + font-size: 13px; + color: #b4b4b4; + background: $color-white; + border: 1px solid #eee; + border-radius: 3px; + padding: 6px 10px; + cursor: text; + transition: $transition-speed $transition-easing; + transition-property: border-color; + + &.item-text--empty { + font-style: italic; + font-size: 11px; + color: #ddd; + } + + &:hover, + &:focus { + border-color: $color-primary; + } +} + +.item-updatedAt { + color: $color-grey; + font-size: 11px; +} + +.item-textEdit { + margin-top: 10px; +} + +.item-textEdit-cancel { + margin-right: 20px; + color: $color-grey; + font-size: 12px; + cursor: pointer; +} + +.item-textEdit-button { + padding: 1px 9px; +} + +.item-textEdit-actions { + display: flex; + justify-content: flex-end; + align-items: center; + margin-top: 10px; +} + +@media (max-width: ($screen-sm)) { + .item-edit { + display: none; + } +} diff --git a/webapp/app/pods/components/translations-list/item/template.hbs b/webapp/app/pods/components/translations-list/item/template.hbs new file mode 100644 index 00000000..795e7959 --- /dev/null +++ b/webapp/app/pods/components/translations-list/item/template.hbs @@ -0,0 +1,86 @@ + + + + + {{#if isInEditMode}} + {{inline-svg 'assets/x.svg' class='item-edit-icon'}} + {{else}} + {{inline-svg 'assets/pencil.svg' class='item-edit-icon'}} + {{/if}} + + + + {{#link-to + 'logged-in.project.translation' + project.id + translation.id + class='item-link' + }} + + {{translationKey.prefix}} + {{translationKey.value}} + + {{/link-to}} + + + + {{#if translation.isConflicted}} + {{#acc-badge + link=true + primary=true + }} + {{#link-to + 'logged-in.project.revision.conflicts' + project.id + revisionId + (query-params query=translation.id) + }} + {{t 'components.translations_list.in_review_label'}} + {{/link-to}} + {{/acc-badge}} + {{/if}} + + {{#if translation.commentsCount}} + {{#acc-badge link=true}} + {{#link-to + 'logged-in.project.translation.comments' + project.id + translation.id + }} + {{t 'components.translations_list.comments_count' count=translation.commentsCount}} + {{/link-to}} + {{/acc-badge}} + {{/if}} + + + {{t 'components.translations_list.last_updated_label'}} + {{time-ago-in-words-tag date=translation.updatedAt}} + + + + +{{#if isInEditMode}} +
    + {{translation-edit/form + disabled=translation.isRemoved + valueType=translation.valueType + value=editText + onSubmit=(action 'save') + }} + +
    + Cancel + {{#async-button + onClick=(action 'save') + loading=isSaving + class='button button--filled button--iconOnly item-textEdit-button' + }} + Save + {{/async-button}} +
    +
    +{{else if isTextEmpty}} + {{t 'components.translations_list.empty_text'}} +{{else}} + {{translation.correctedText}} +{{/if}} diff --git a/webapp/app/pods/components/translations-list/styles.scss b/webapp/app/pods/components/translations-list/styles.scss new file mode 100644 index 00000000..86146d8c --- /dev/null +++ b/webapp/app/pods/components/translations-list/styles.scss @@ -0,0 +1,3 @@ +& { + margin-top: 20px; +} diff --git a/webapp/app/pods/components/translations-list/template.hbs b/webapp/app/pods/components/translations-list/template.hbs new file mode 100644 index 00000000..c7cec547 --- /dev/null +++ b/webapp/app/pods/components/translations-list/template.hbs @@ -0,0 +1,28 @@ +
      + {{#each translations key='id' as |translation|}} + {{translations-list/item + translation=translation + project=project + revisionId=revisionId + onUpdateText=onUpdateText + }} + {{else if query}} + {{empty-content + iconPath='assets/empty.svg' + text=(t 'components.translations_list.no_translations_query' query=query) + }} + {{else}} + {{#empty-content}} + {{inline-svg 'assets/empty.svg' class='icon'}} + {{t 'components.translations_list.no_translations'}} + +
      + {{t 'components.translations_list.maybe_sync_before'}} + + {{#link-to 'logged-in.project.files' class='link'}} + {{t 'components.translations_list.maybe_sync_link'}} + {{/link-to}} +
      + {{/empty-content}} + {{/each}} +
    diff --git a/webapp/app/pods/components/version-create-form/component.js b/webapp/app/pods/components/version-create-form/component.js new file mode 100644 index 00000000..6380bef3 --- /dev/null +++ b/webapp/app/pods/components/version-create-form/component.js @@ -0,0 +1,29 @@ +import Component from '@ember/component'; +import {scheduleOnce} from '@ember/runloop'; + +// Attributes: +// error: Boolean +// onCreate: Function +export default Component.extend({ + name: null, + tag: null, + isCreating: false, + + didInsertElement() { + scheduleOnce('afterRender', this, function() { + this.element.querySelector('.textInput').focus(); + }); + }, + + actions: { + submit() { + this.set('isCreating', true); + const tag = this.tag; + const name = this.name; + + this.onCreate({tag, name}).then(() => { + if (!this.isDestroyed) this.set('isCreating', false); + }); + } + } +}); diff --git a/webapp/app/pods/components/version-create-form/styles.scss b/webapp/app/pods/components/version-create-form/styles.scss new file mode 100644 index 00000000..99fbb0d5 --- /dev/null +++ b/webapp/app/pods/components/version-create-form/styles.scss @@ -0,0 +1,58 @@ +& { + padding: 20px; + background: #fff; +} + +.title { + margin-bottom: 20px; + text-align: center; + font-size: 27px; + font-weight: 300; + color: $color-primary; +} + +.text { + font-size: 13px; + margin-bottom: 20px; + color: #555; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 10px; + margin-right: 25px; + min-width: 250px; + width: 100%; + font-size: 12px; + font-family: $font-primary; +} + +.errors { + margin-bottom: 15px; + padding-bottom: 5px; + border-bottom: 1px solid rgba($color-error, 0.3); +} + +.error { + margin-bottom: 5px; + color: $color-error; + font-size: 13px; + font-weight: bold; +} + +.formItem { + margin-bottom: 20px; +} + +.formItem-label { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.formActions { + padding-top: 20px; + border-top: 1px solid #eee; +} diff --git a/webapp/app/pods/components/version-create-form/template.hbs b/webapp/app/pods/components/version-create-form/template.hbs new file mode 100644 index 00000000..515b0aa8 --- /dev/null +++ b/webapp/app/pods/components/version-create-form/template.hbs @@ -0,0 +1,55 @@ +

    + {{t 'components.version_create_form.title'}} +

    + +
    + {{t 'components.version_create_form.text'}} +
    + +{{#if error}} +
    +
    + {{t 'components.version_create_form.error'}} +
    +
    +{{/if}} + +
    + + + {{input + value=name + class='textInput' + }} +
    + +
    + + + {{input + value=tag + class='textInput' + }} +
    + +
    + {{#link-to + 'logged-in.project.versions' + project.id + class='button button--filled button--white' + }} + {{t 'components.version_create_form.cancel_button'}} + {{/link-to}} + + {{#async-button + class='button button--filled' + loading=isCreating + onClick=(action 'submit') + }} + {{t 'components.version_create_form.save_button'}} + {{/async-button}} +
    diff --git a/webapp/app/pods/components/version-update-form/component.js b/webapp/app/pods/components/version-update-form/component.js new file mode 100644 index 00000000..3bcaea02 --- /dev/null +++ b/webapp/app/pods/components/version-update-form/component.js @@ -0,0 +1,31 @@ +import {reads} from '@ember/object/computed'; +import {scheduleOnce} from '@ember/runloop'; +import Component from '@ember/component'; + +// Attributes: +// version: Object +// error: Boolean +// onCreate: Function +export default Component.extend({ + name: reads('version.name'), + tag: reads('version.tag'), + isSubmitting: false, + + didInsertElement() { + scheduleOnce('afterRender', this, function() { + this.element.querySelector('.textInput').focus(); + }); + }, + + actions: { + submit() { + this.set('isSubmitting', true); + const tag = this.tag; + const name = this.name; + + this.onUpdate({tag, name}).then(() => { + if (!this.isDestroyed) this.set('isSubmitting', false); + }); + } + } +}); diff --git a/webapp/app/pods/components/version-update-form/styles.scss b/webapp/app/pods/components/version-update-form/styles.scss new file mode 100644 index 00000000..99fbb0d5 --- /dev/null +++ b/webapp/app/pods/components/version-update-form/styles.scss @@ -0,0 +1,58 @@ +& { + padding: 20px; + background: #fff; +} + +.title { + margin-bottom: 20px; + text-align: center; + font-size: 27px; + font-weight: 300; + color: $color-primary; +} + +.text { + font-size: 13px; + margin-bottom: 20px; + color: #555; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 10px; + margin-right: 25px; + min-width: 250px; + width: 100%; + font-size: 12px; + font-family: $font-primary; +} + +.errors { + margin-bottom: 15px; + padding-bottom: 5px; + border-bottom: 1px solid rgba($color-error, 0.3); +} + +.error { + margin-bottom: 5px; + color: $color-error; + font-size: 13px; + font-weight: bold; +} + +.formItem { + margin-bottom: 20px; +} + +.formItem-label { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.formActions { + padding-top: 20px; + border-top: 1px solid #eee; +} diff --git a/webapp/app/pods/components/version-update-form/template.hbs b/webapp/app/pods/components/version-update-form/template.hbs new file mode 100644 index 00000000..7e6e412d --- /dev/null +++ b/webapp/app/pods/components/version-update-form/template.hbs @@ -0,0 +1,51 @@ +

    + {{t 'components.version_update_form.title'}} +

    + +{{#if error}} +
    +
    + {{t 'components.version_update_form.error'}} +
    +
    +{{/if}} + +
    + + + {{input + value=name + class='textInput' + }} +
    + +
    + + + {{input + value=tag + class='textInput' + }} +
    + +
    + {{#link-to + 'logged-in.project.versions' + project.id + class='button button--filled button--white' + }} + {{t 'components.version_update_form.cancel_button'}} + {{/link-to}} + + {{#async-button + class='button button--filled' + loading=isCreating + onClick=(action 'submit') + }} + {{t 'components.version_update_form.save_button'}} + {{/async-button}} +
    diff --git a/webapp/app/pods/components/versions-add-button/styles.scss b/webapp/app/pods/components/versions-add-button/styles.scss new file mode 100644 index 00000000..fabb070a --- /dev/null +++ b/webapp/app/pods/components/versions-add-button/styles.scss @@ -0,0 +1,26 @@ +.button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: 100%; + padding: 20px; + background: #fff; + border: 1px solid #eee; + border-radius: 3px; + color: $color-primary; + font-weight: normal; + font-size: 18px; + + &:focus, + &:hover { + background: lighten($color-primary, 50%); + } +} + +.button-icon { + margin: 0 0 10px; + width: 30px; + height: 30px; + fill: $color-primary; +} diff --git a/webapp/app/pods/components/versions-add-button/template.hbs b/webapp/app/pods/components/versions-add-button/template.hbs new file mode 100644 index 00000000..e06b4318 --- /dev/null +++ b/webapp/app/pods/components/versions-add-button/template.hbs @@ -0,0 +1,8 @@ +{{#link-to + 'logged-in.project.versions.new' + project.id + class='button' +}} + {{inline-svg '/assets/add.svg' class='button-icon'}} + {{t 'components.versions_add_button.link'}} +{{/link-to}} diff --git a/webapp/app/pods/components/versions-list/component.js b/webapp/app/pods/components/versions-list/component.js new file mode 100644 index 00000000..da09569a --- /dev/null +++ b/webapp/app/pods/components/versions-list/component.js @@ -0,0 +1,10 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// versions: Array of +// onDelete: Function +export default Component.extend({ + tagName: 'ul' +}); diff --git a/webapp/app/pods/components/versions-list/item/component.js b/webapp/app/pods/components/versions-list/item/component.js new file mode 100644 index 00000000..e78fb00b --- /dev/null +++ b/webapp/app/pods/components/versions-list/item/component.js @@ -0,0 +1,12 @@ +import Component from '@ember/component'; + +// Attributes: +// project: Object +// permissions: Ember Object containing +// document: Object +// onDelete: Function +export default Component.extend({ + tagName: 'li', + + classNames: ['item'] +}); diff --git a/webapp/app/pods/components/versions-list/item/template.hbs b/webapp/app/pods/components/versions-list/item/template.hbs new file mode 100644 index 00000000..f8e48ce1 --- /dev/null +++ b/webapp/app/pods/components/versions-list/item/template.hbs @@ -0,0 +1,54 @@ +
    +

    + {{#if (get permissions 'update_version')}} + {{#link-to + 'logged-in.project.versions.edit' + project.id + version.id + class='item-title-link' + }} + {{version.name}} + {{/link-to}} + {{else}} + {{version.name}} + {{/if}} + + + {{inline-svg '/assets/tag.svg' class='item-tag-icon'}} + {{version.tag}} + +

    +
    + {{version.user.fullname}} + {{time-ago-in-words-tag + date=version.insertedAt + class='item-meta-date' + }} +
    +
    + + diff --git a/webapp/app/pods/components/versions-list/styles.scss b/webapp/app/pods/components/versions-list/styles.scss new file mode 100644 index 00000000..32a289de --- /dev/null +++ b/webapp/app/pods/components/versions-list/styles.scss @@ -0,0 +1,73 @@ +& { + width: 100%; + margin-top: 15px; +} + +.item { + position: relative; + margin-bottom: 20px; + padding: 10px 0; + width: 100%; + + &:focus, + &:hover { + .deleteButton { + display: inline-block; + } + } +} + +.item-title { + display: flex; + align-items: center; + font-size: 23px; + color: $color-primary; +} + +.item-title-link { + text-decoration: none; + color: $color-primary; + + &:hover { + text-decoration: underline; + } +} + +.item-tag { + display: inline-flex; + align-items: center; + margin-left: 10px; + font-family: $font-monospace; + font-size: 14px; + font-weight: normal; + color: #888; +} + +.item-tag-icon { + width: 22px; + height: 22px; + fill: #bbb; +} + +.item-meta { + margin-bottom: 10px; + font-size: 12px; + color: #555; +} + +.item-meta-date { + font-style: italic; + color: #bbb; +} + +.links { + margin-top: 10px; + + .button { + margin-right: 6px; + + &.button--borderLess:first-of-type:last-of-type { + margin-left: -14px; + } + } +} diff --git a/webapp/app/pods/components/versions-list/template.hbs b/webapp/app/pods/components/versions-list/template.hbs new file mode 100644 index 00000000..2c0c2709 --- /dev/null +++ b/webapp/app/pods/components/versions-list/template.hbs @@ -0,0 +1,7 @@ +{{#each versions key='id' as |version|}} + {{versions-list/item + permissions=permissions + version=version + project=project + }} +{{/each}} diff --git a/webapp/app/pods/components/welcome-project/styles.scss b/webapp/app/pods/components/welcome-project/styles.scss new file mode 100644 index 00000000..119c52a6 --- /dev/null +++ b/webapp/app/pods/components/welcome-project/styles.scss @@ -0,0 +1,71 @@ +& { + width: 100%; + margin: 0 30px; +} + +.heroTitle { + display: block; + margin: 20px 0 40px; + max-width: 350px; + font-weight: 300; +} + +.heroTitle-title { + font-size: 48px; + color: $color-primary; +} + +.heroTitle-text { + font-size: 18px; + color: lighten($color-grey, 10%); +} + +.subtitle { + padding-bottom: 5px; + margin-bottom: 10px; + border-bottom: 1px solid #eee; + font-weight: bold; + font-size: 16px; +} + +.section, +.link { + display: flex; + align-items: center; + padding: 5px 0; +} + +.link { + margin-bottom: 15px; + color: $color-primary; + text-decoration: none; +} + +.section { + color: $color-grey; +} + +.section-icon, +.link-icon { + width: 24px; + height: 24px; + margin-right: 5px; +} + +.link-icon { + fill: $color-primary; +} + +.section-icon { + fill: $color-grey; +} + +.help { + margin: 20px 0; + font-size: 12px; + font-weight: bold; +} + +.help-link { + color: $color-primary; +} diff --git a/webapp/app/pods/components/welcome-project/template.hbs b/webapp/app/pods/components/welcome-project/template.hbs new file mode 100644 index 00000000..78397e13 --- /dev/null +++ b/webapp/app/pods/components/welcome-project/template.hbs @@ -0,0 +1,30 @@ +

    +
    + {{t 'components.welcome_project.welcome'}} +
    + +
    + {{t 'components.welcome_project.welcome_translations'}} +
    +

    + +

    {{t 'components.welcome_project.first_step'}}:

    +{{#link-to + 'logged-in.project.files.new-sync' + project.id + class='link' +}} + {{inline-svg 'assets/sync.svg' class='link-icon'}} + {{t 'components.welcome_project.sync_file'}} +{{/link-to}} + +

    {{t 'components.welcome_project.after_steps'}}:

    +
    + {{inline-svg 'assets/check.svg' class='section-icon'}} + {{t 'components.welcome_project.start_review'}} +
    + +
    + {{inline-svg 'assets/export.svg' class='section-icon'}} + {{t 'components.welcome_project.export'}} +
    diff --git a/webapp/app/pods/error/controller.js b/webapp/app/pods/error/controller.js new file mode 100644 index 00000000..2bb3e2fd --- /dev/null +++ b/webapp/app/pods/error/controller.js @@ -0,0 +1,39 @@ +import {inject as service} from '@ember/service'; +import {equal} from '@ember/object/computed'; +import Controller from '@ember/controller'; +import {computed} from '@ember/object'; + +const UNAUTHORIZED = 'unauthorized'; +const INTERNAL_ERROR = 'internal_error'; + +const translationAttribute = attribute => { + return computed('translationPrefix', function() { + return this.i18n.t(`pods.error.${this.translationPrefix}.${attribute}`); + }); +}; + +export default Controller.extend({ + i18n: service('i18n'), + session: service('session'), + + isUnauthorized: equal('firstError.status', '401'), + + title: translationAttribute('title'), + status: translationAttribute('status'), + text: translationAttribute('text'), + + firstError: computed('model.errors.[]', function() { + return this.model.errors[0]; + }), + + translationPrefix: computed('isUnauthorized', function() { + return this.isUnauthorized ? UNAUTHORIZED : INTERNAL_ERROR; + }), + + actions: { + logout() { + this.session.logout(); + window.location = '/'; + } + } +}); diff --git a/webapp/app/pods/error/template.hbs b/webapp/app/pods/error/template.hbs new file mode 100644 index 00000000..cac42d7b --- /dev/null +++ b/webapp/app/pods/error/template.hbs @@ -0,0 +1,7 @@ +{{error-section + status=status + title=title + text=text + onLogout=(action 'logout') + isAuthenticated=session.isAuthenticated +}} diff --git a/webapp/app/pods/index/route.js b/webapp/app/pods/index/route.js new file mode 100644 index 00000000..62d3670d --- /dev/null +++ b/webapp/app/pods/index/route.js @@ -0,0 +1,12 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; + +export default Route.extend({ + session: service('session'), + + redirect() { + const newRoute = !this.session.credentials ? 'home' : 'logged-in.projects'; + + this.transitionTo(newRoute); + } +}); diff --git a/webapp/app/pods/logged-in/controller.js b/webapp/app/pods/logged-in/controller.js new file mode 100644 index 00000000..23fd8b2a --- /dev/null +++ b/webapp/app/pods/logged-in/controller.js @@ -0,0 +1,7 @@ +import {inject as service} from '@ember/service'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + session: service('session'), + flashMessages: service() +}); diff --git a/webapp/app/pods/logged-in/project/activities/controller.js b/webapp/app/pods/logged-in/project/activities/controller.js new file mode 100644 index 00000000..7db6e1b7 --- /dev/null +++ b/webapp/app/pods/logged-in/project/activities/controller.js @@ -0,0 +1,43 @@ +import {inject as service} from '@ember/service'; +import {readOnly, not, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + queryParams: ['batchFilter', 'actionFilter', 'userFilter', 'page'], + + batchFilter: null, + actionFilter: null, + userFilter: null, + page: 1, + + permissions: readOnly('globalState.permissions'), + emptyEntries: not('model.activities', undefined), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + batchFilterChange(checked) { + this.set('batchFilter', checked ? true : null); + this.set('page', 1); + }, + + actionFilterChange(action) { + this.set('actionFilter', action); + this.set('page', 1); + }, + + userFilterChange(user) { + this.set('userFilter', user); + this.set('page', 1); + }, + + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/activities/route.js b/webapp/app/pods/logged-in/project/activities/route.js new file mode 100644 index 00000000..65169d65 --- /dev/null +++ b/webapp/app/pods/logged-in/project/activities/route.js @@ -0,0 +1,56 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectActivitiesQuery from 'accent-webapp/queries/project-activities'; + +export default Route.extend(ApolloRoute, { + queryParams: { + batchFilter: { + refreshModel: true + }, + actionFilter: { + refreshModel: true + }, + userFilter: { + refreshModel: true + }, + page: { + refreshModel: true + } + }, + + model({batchFilter, actionFilter, userFilter, page}, transition) { + return this.graphql(projectActivitiesQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + activities: get(data, 'viewer.project.activities'), + collaborators: get(data, 'viewer.project.collaborators') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + isBatch: batchFilter ? true : null, + action: actionFilter, + userId: userFilter, + page + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: 1 + }); + } + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/activities/template.hbs b/webapp/app/pods/logged-in/project/activities/template.hbs new file mode 100644 index 00000000..20c4ef6d --- /dev/null +++ b/webapp/app/pods/logged-in/project/activities/template.hbs @@ -0,0 +1,41 @@ +{{page-title + text=(t 'components.project_activities_list.title') + icon='assets/activity.svg' +}} + +{{#if showSkeleton}} + {{skeleton-ui/project-activities-filter}} + + {{#if model.loading}} + {{skeleton-ui/progress-line}} + {{/if}} + + {{skeleton-ui/activities-list showTranslationLink=true}} +{{else}} + {{#if model.collaborators}} + {{project-activities-filter + collaborators=model.collaborators + batchFilter=batchFilter + actionFilter=actionFilter + userFilter=userFilter + userFilterChange=(action 'userFilterChange') + batchFilterChange=(action 'batchFilterChange') + actionFilterChange=(action 'actionFilterChange') + }} + {{/if}} + + {{#if model.loading}} + {{skeleton-ui/progress-line}} + {{/if}} + + {{project-activities-list + permissions=permissions + activities=model.activities.entries + project=model.project + }} + + {{resource-pagination + meta=model.activities.meta + onSelectPage=(action 'selectPage') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/activity/controller.js b/webapp/app/pods/logged-in/project/activity/controller.js new file mode 100644 index 00000000..43171abc --- /dev/null +++ b/webapp/app/pods/logged-in/project/activity/controller.js @@ -0,0 +1,36 @@ +import {inject as service} from '@ember/service'; +import {readOnly} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import operationRollbackQuery from 'accent-webapp/queries/rollback-operation'; + +const FLASH_MESSAGE_OPERATION_ROLLBACK_SUCCESS = 'pods.project.activities.flash_messages.rollback_success'; +const FLASH_MESSAGE_OPERATION_ROLLBACK_ERROR = 'pods.project.activities.flash_messages.rollback_error'; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + queryParams: ['activitiesPage'], + + permissions: readOnly('globalState.permissions'), + + actions: { + rollback() { + return this.apolloMutate + .mutate({ + mutation: operationRollbackQuery, + variables: { + operationId: this.model.activity.id + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_OPERATION_ROLLBACK_SUCCESS)); + this.send('onRefresh'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_OPERATION_ROLLBACK_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/activity/route.js b/webapp/app/pods/logged-in/project/activity/route.js new file mode 100644 index 00000000..abc63204 --- /dev/null +++ b/webapp/app/pods/logged-in/project/activity/route.js @@ -0,0 +1,29 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectActivityQuery from 'accent-webapp/queries/project-activity'; + +export default Route.extend(ApolloRoute, { + model({activityId}, transition) { + return this.graphql(projectActivityQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + activity: get(data, 'viewer.project.activity') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + activityId + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/activity/template.hbs b/webapp/app/pods/logged-in/project/activity/template.hbs new file mode 100644 index 00000000..b85b9d7e --- /dev/null +++ b/webapp/app/pods/logged-in/project/activity/template.hbs @@ -0,0 +1,12 @@ +{{#if model.loading}} + {{loading-content label=(t 'pods.project.activities.show.loading_content')}} +{{else}} + {{project-activity + permissions=permissions + showTranslationLink=true + componentTranslationPrefix='project_activities_list_item' + project=model.project + activity=model.activity + onRollback=(action 'rollback') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/comments/controller.js b/webapp/app/pods/logged-in/project/comments/controller.js new file mode 100644 index 00000000..329da55b --- /dev/null +++ b/webapp/app/pods/logged-in/project/comments/controller.js @@ -0,0 +1,17 @@ +import {equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + queryParams: ['page'], + page: 1, + + emptyEntries: equal('model.comments.entries', undefined), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/comments/route.js b/webapp/app/pods/logged-in/project/comments/route.js new file mode 100644 index 00000000..39080959 --- /dev/null +++ b/webapp/app/pods/logged-in/project/comments/route.js @@ -0,0 +1,39 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectCommentsQuery from 'accent-webapp/queries/project-comments'; + +export default Route.extend(ApolloRoute, { + queryParams: { + page: { + refreshModel: true + } + }, + + model({page}, transition) { + if (page) page = parseInt(page, 10); + + return this.graphql(projectCommentsQuery, { + props: data => ({ + comments: get(data, 'viewer.project.comments'), + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + page + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: 1 + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/comments/template.hbs b/webapp/app/pods/logged-in/project/comments/template.hbs new file mode 100644 index 00000000..9812e3a2 --- /dev/null +++ b/webapp/app/pods/logged-in/project/comments/template.hbs @@ -0,0 +1,22 @@ +{{page-title + text=(t 'components.project_comments_list.title') + icon='assets/bubble.svg' +}} + +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/project-comments-list}} +{{else}} + {{project-comments-list + project=model.project + comments=model.comments.entries + }} + + {{resource-pagination + meta=model.comments.meta + onSelectPage=(action 'selectPage') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/controller.js b/webapp/app/pods/logged-in/project/controller.js new file mode 100644 index 00000000..f020fb8a --- /dev/null +++ b/webapp/app/pods/logged-in/project/controller.js @@ -0,0 +1,28 @@ +import {observer} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads, readOnly, not, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + session: service(), + globalState: service('global-state'), + + project: reads('model.project'), + revisions: reads('project.revisions'), + permissions: readOnly('globalState.permissions'), + noProject: not('project'), + notLoading: not('model.loading'), + showError: and('noProject', 'notLoading'), + + permissionsObserver: observer('model.permissions', function() { + this.globalState.set('permissions', this.model.permissions); + }), + + rolesObserver: observer('model.roles', function() { + this.globalState.set('roles', this.model.roles); + }), + + documentFormatsObserver: observer('model.documentFormats', function() { + this.globalState.set('documentFormats', this.model.documentFormats); + }) +}); diff --git a/webapp/app/pods/logged-in/project/edit/api-token/controller.js b/webapp/app/pods/logged-in/project/edit/api-token/controller.js new file mode 100644 index 00000000..79a4ef09 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/api-token/controller.js @@ -0,0 +1,15 @@ +import {inject as service} from '@ember/service'; +import {reads, readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + i18n: service(), + globalState: service('global-state'), + + project: reads('model.project'), + + permissions: readOnly('globalState.permissions'), + + emptyData: equal('model.project.name', undefined), + showLoading: and('emptyData', 'model.loading') +}); diff --git a/webapp/app/pods/logged-in/project/edit/api-token/route.js b/webapp/app/pods/logged-in/project/edit/api-token/route.js new file mode 100644 index 00000000..ad357a15 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/api-token/route.js @@ -0,0 +1,21 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectApiTokenQuery from 'accent-webapp/queries/project-api-token'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectApiTokenQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/api-token/template.hbs b/webapp/app/pods/logged-in/project/edit/api-token/template.hbs new file mode 100644 index 00000000..d1a6c2b5 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/api-token/template.hbs @@ -0,0 +1,9 @@ +{{#if showLoading}} + {{loading-content label=(t 'pods.project.edit.api_token.loading_content')}} +{{else}} + {{project-settings/back-link project=project}} + + {{#if project.accessToken}} + {{project-settings/api-token token=project.accessToken}} + {{/if}} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/badges/controller.js b/webapp/app/pods/logged-in/project/edit/badges/controller.js new file mode 100644 index 00000000..f3f5b185 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/badges/controller.js @@ -0,0 +1,8 @@ +import {reads, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + project: reads('model.project'), + emptyData: equal('model.project.name', undefined), + showLoading: and('emptyData', 'model.loading') +}); diff --git a/webapp/app/pods/logged-in/project/edit/badges/route.js b/webapp/app/pods/logged-in/project/edit/badges/route.js new file mode 100644 index 00000000..a372abb8 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/badges/route.js @@ -0,0 +1,27 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectEditQuery from 'accent-webapp/queries/project-edit'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectEditQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/badges/template.hbs b/webapp/app/pods/logged-in/project/edit/badges/template.hbs new file mode 100644 index 00000000..0e438dc0 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/badges/template.hbs @@ -0,0 +1,7 @@ +{{#if showLoading}} + {{loading-content label=(t 'pods.project.edit.badges.loading_content')}} +{{else}} + {{project-settings/back-link project=project}} + + {{project-settings/badges project=project}} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/collaborators/controller.js b/webapp/app/pods/logged-in/project/edit/collaborators/controller.js new file mode 100644 index 00000000..4fbb2bce --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/collaborators/controller.js @@ -0,0 +1,81 @@ +import {inject as service} from '@ember/service'; +import {reads, readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import collaboratorCreateQuery from 'accent-webapp/queries/create-collaborator'; +import collaboratorDeleteQuery from 'accent-webapp/queries/delete-collaborator'; +import collaboratorUpdateQuery from 'accent-webapp/queries/update-collaborator'; + +const FLASH_MESSAGE_PREFIX = 'pods.project.edit.flash_messages.'; +const FLASH_MESSAGE_COLLABORATOR_ADD_SUCCESS = `${FLASH_MESSAGE_PREFIX}collaborator_add_success`; +const FLASH_MESSAGE_COLLABORATOR_ADD_ERROR = `${FLASH_MESSAGE_PREFIX}collaborator_add_error`; +const FLASH_MESSAGE_COLLABORATOR_REMOVE_SUCCESS = `${FLASH_MESSAGE_PREFIX}collaborator_remove_success`; +const FLASH_MESSAGE_COLLABORATOR_REMOVE_ERROR = `${FLASH_MESSAGE_PREFIX}collaborator_remove_error`; +const FLASH_MESSAGE_COLLABORATOR_UPDATE_SUCCESS = `${FLASH_MESSAGE_PREFIX}collaborator_update_success`; +const FLASH_MESSAGE_COLLABORATOR_UPDATE_ERROR = `${FLASH_MESSAGE_PREFIX}collaborator_update_error`; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + project: reads('model.project'), + collaborators: reads('project.collaborators'), + + permissions: readOnly('globalState.permissions'), + + emptyData: equal('model.project.name', undefined), + showLoading: and('emptyData', 'model.loading'), + + actions: { + createCollaborator({email, role}) { + const project = this.project; + + return this._mutateResource({ + mutation: collaboratorCreateQuery, + successMessage: FLASH_MESSAGE_COLLABORATOR_ADD_SUCCESS, + errorMessage: FLASH_MESSAGE_COLLABORATOR_ADD_ERROR, + variables: { + projectId: project.id, + email, + role + } + }); + }, + + updateCollaborator(collaborator, {role}) { + return this._mutateResource({ + mutation: collaboratorUpdateQuery, + successMessage: FLASH_MESSAGE_COLLABORATOR_UPDATE_SUCCESS, + errorMessage: FLASH_MESSAGE_COLLABORATOR_UPDATE_ERROR, + variables: { + collaboratorId: collaborator.id, + role + } + }); + }, + + deleteCollaborator(collaborator) { + return this._mutateResource({ + mutation: collaboratorDeleteQuery, + successMessage: FLASH_MESSAGE_COLLABORATOR_REMOVE_SUCCESS, + errorMessage: FLASH_MESSAGE_COLLABORATOR_REMOVE_ERROR, + variables: { + collaboratorId: collaborator.id + } + }); + } + }, + + _mutateResource({mutation, variables, successMessage, errorMessage}) { + return this.apolloMutate + .mutate({ + mutation, + variables, + refetchQueries: ['ProjectCollaborators'] + }) + .then(() => this.flashMessages.success(this.i18n.t(successMessage))) + .catch(() => this.flashMessages.error(this.i18n.t(errorMessage))); + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/collaborators/route.js b/webapp/app/pods/logged-in/project/edit/collaborators/route.js new file mode 100644 index 00000000..68186550 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/collaborators/route.js @@ -0,0 +1,27 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectCollaboratorsQuery from 'accent-webapp/queries/project-collaborators'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectCollaboratorsQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/collaborators/template.hbs b/webapp/app/pods/logged-in/project/edit/collaborators/template.hbs new file mode 100644 index 00000000..c12c9a57 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/collaborators/template.hbs @@ -0,0 +1,14 @@ +{{#if showLoading}} + {{loading-content label=(t 'pods.project.edit.collaborators.loading_content')}} +{{else}} + {{project-settings/back-link project=project}} + + {{project-settings/collaborators + project=project + permissions=permissions + collaborators=collaborators + onCreateCollaborator=(action 'createCollaborator') + onUpdateCollaborator=(action 'updateCollaborator') + onDeleteCollaborator=(action 'deleteCollaborator') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/index/controller.js b/webapp/app/pods/logged-in/project/edit/index/controller.js new file mode 100644 index 00000000..17a368da --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/index/controller.js @@ -0,0 +1,71 @@ +import {inject as service} from '@ember/service'; +import {reads, readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import projectUpdateQuery from 'accent-webapp/queries/update-project'; +import projectDeleteQuery from 'accent-webapp/queries/delete-project'; + +const FLASH_MESSAGE_PREFIX = 'pods.project.edit.flash_messages.'; +const FLASH_MESSAGE_PROJECT_SUCCESS = `${FLASH_MESSAGE_PREFIX}update_success`; +const FLASH_MESSAGE_PROJECT_ERROR = `${FLASH_MESSAGE_PREFIX}update_error`; +const FLASH_MESSAGE_DELETE_PROJECT_SUCCESS = `${FLASH_MESSAGE_PREFIX}delete_success`; +const FLASH_MESSAGE_DELETE_PROJECT_ERROR = `${FLASH_MESSAGE_PREFIX}delete_error`; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + project: reads('model.project'), + + permissions: readOnly('globalState.permissions'), + + emptyData: equal('model.project.name', undefined), + showLoading: and('emptyData', 'model.loading'), + + actions: { + deleteProject() { + const project = this.project; + + return this.apolloMutate + .mutate({ + mutation: projectDeleteQuery, + variables: { + projectId: project.id + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_DELETE_PROJECT_SUCCESS)); + this.transitionToRoute('logged-in.projects'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_DELETE_PROJECT_ERROR))); + }, + + updateProject({name, isFileOperationsLocked}) { + const project = this.project; + + return this._mutateResource({ + mutation: projectUpdateQuery, + successMessage: FLASH_MESSAGE_PROJECT_SUCCESS, + errorMessage: FLASH_MESSAGE_PROJECT_ERROR, + variables: { + projectId: project.id, + name, + isFileOperationsLocked + } + }); + } + }, + + _mutateResource({mutation, variables, successMessage, errorMessage}) { + return this.apolloMutate + .mutate({ + mutation, + variables, + refetchQueries: ['ProjectEdit'] + }) + .then(() => this.flashMessages.success(this.i18n.t(successMessage))) + .catch(() => this.flashMessages.error(this.i18n.t(errorMessage))); + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/index/route.js b/webapp/app/pods/logged-in/project/edit/index/route.js new file mode 100644 index 00000000..a372abb8 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/index/route.js @@ -0,0 +1,27 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectEditQuery from 'accent-webapp/queries/project-edit'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectEditQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/index/template.hbs b/webapp/app/pods/logged-in/project/edit/index/template.hbs new file mode 100644 index 00000000..a4ab0df5 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/index/template.hbs @@ -0,0 +1,21 @@ +{{#if showLoading}} + {{loading-content label=(t 'pods.project.edit.loading_content')}} +{{else}} + {{project-settings/form + project=project + permissions=permissions + onUpdateProject=(action 'updateProject') + }} + + {{project-settings/links-list + project=project + permissions=permissions + }} + + {{#if (get permissions 'delete_project')}} + {{project-settings/delete-form + project=project + onSubmit=(action 'deleteProject') + }} + {{/if}} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/manage-languages/controller.js b/webapp/app/pods/logged-in/project/edit/manage-languages/controller.js new file mode 100644 index 00000000..4bb1fdef --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/manage-languages/controller.js @@ -0,0 +1,88 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import revisionCreateQuery from 'accent-webapp/queries/create-revision'; +import revisionDeleteQuery from 'accent-webapp/queries/delete-revision'; +import revisionMasterPromoteQuery from 'accent-webapp/queries/promote-master-revision'; + +const FLASH_MESSAGE_NEW_LANGUAGE_SUCCESS = 'pods.project.manage_languages.flash_messages.add_revision_success'; +const FLASH_MESSAGE_NEW_LANGUAGE_FAILURE = 'pods.project.manage_languages.flash_messages.add_revision_failure'; +const FLASH_MESSAGE_REVISION_DELETED_SUCCESS = 'pods.project.manage_languages.flash_messages.delete_revision_success'; +const FLASH_MESSAGE_REVISION_DELETED_ERROR = 'pods.project.manage_languages.flash_messages.delete_revision_failure'; +const FLASH_MESSAGE_REVISION_MASTER_PROMOTED_SUCCESS = + 'pods.project.manage_languages.flash_messages.promote_master_revision_success'; +const FLASH_MESSAGE_REVISION_MASTER_PROMOTED_ERROR = + 'pods.project.manage_languages.flash_messages.promote_master_revision_failure'; + +export default Controller.extend({ + flashMessages: service(), + i18n: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + permissions: readOnly('globalState.permissions'), + emptyLanguages: equal('model.languages', undefined), + showLoading: and('emptyLanguages', 'model.loading'), + + errors: computed(() => []), + + filteredLanguages: computed('model.{languages.[],project.revisions}', function() { + const projectLanguages = this.model.project.revisions.map(revision => revision.language.id); + + return this.model.languages.filter(({id}) => !projectLanguages.includes(id)); + }), + + actions: { + deleteRevision(revision) { + return this.apolloMutate + .mutate({ + mutation: revisionDeleteQuery, + variables: { + revisionId: revision.id + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_REVISION_DELETED_SUCCESS)); + this.send('onRefresh'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_REVISION_DELETED_ERROR))); + }, + + promoteRevisionMaster(revision) { + return this.apolloMutate + .mutate({ + mutation: revisionMasterPromoteQuery, + variables: { + revisionId: revision.id + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_REVISION_MASTER_PROMOTED_SUCCESS)); + this.send('onRefresh'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_REVISION_MASTER_PROMOTED_ERROR))); + }, + + create(languageId) { + const project = this.model.project; + this.set('errors', []); + + return this.apolloMutate + .mutate({ + mutation: revisionCreateQuery, + refetchQueries: ['Dashboard', 'Project'], + variables: { + projectId: project.id, + languageId + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_NEW_LANGUAGE_SUCCESS)); + this.transitionToRoute('logged-in.project.index', project.id); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_NEW_LANGUAGE_FAILURE))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/manage-languages/route.js b/webapp/app/pods/logged-in/project/edit/manage-languages/route.js new file mode 100644 index 00000000..e45bcf03 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/manage-languages/route.js @@ -0,0 +1,36 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectNewLanguageQuery from 'accent-webapp/queries/project-new-language'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectNewLanguageQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + languages: get(data, 'languages.entries') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + errors: [] + }); + } + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/manage-languages/template.hbs b/webapp/app/pods/logged-in/project/edit/manage-languages/template.hbs new file mode 100644 index 00000000..621b9185 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/manage-languages/template.hbs @@ -0,0 +1,20 @@ +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showLoading}} + {{loading-content label=(t 'pods.project.manage_languages.loading_content')}} +{{else}} + {{project-settings/back-link project=model.project}} + + {{project-settings/manage-languages + project=model.project + revisions=model.project.revisions + permissions=permissions + languages=filteredLanguages + errors=errors + onPromoteMaster=(action 'promoteRevisionMaster') + onDelete=(action 'deleteRevision') + onCreate=(action 'create') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/service-integrations/controller.js b/webapp/app/pods/logged-in/project/edit/service-integrations/controller.js new file mode 100644 index 00000000..0abcf8c3 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/service-integrations/controller.js @@ -0,0 +1,83 @@ +import {inject as service} from '@ember/service'; +import {reads, readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import integrationCreateQuery from 'accent-webapp/queries/create-integration'; +import integrationUpdateQuery from 'accent-webapp/queries/update-integration'; +import integrationDeleteQuery from 'accent-webapp/queries/delete-integration'; + +const FLASH_MESSAGE_PREFIX = 'pods.project.edit.flash_messages.'; +const FLASH_MESSAGE_INTEGRATION_ADD_SUCCESS = `${FLASH_MESSAGE_PREFIX}integration_add_success`; +const FLASH_MESSAGE_INTEGRATION_ADD_ERROR = `${FLASH_MESSAGE_PREFIX}integration_add_error`; +const FLASH_MESSAGE_INTEGRATION_UPDATE_SUCCESS = `${FLASH_MESSAGE_PREFIX}integration_update_success`; +const FLASH_MESSAGE_INTEGRATION_UPDATE_ERROR = `${FLASH_MESSAGE_PREFIX}integration_update_error`; +const FLASH_MESSAGE_INTEGRATION_REMOVE_SUCCESS = `${FLASH_MESSAGE_PREFIX}integration_remove_success`; +const FLASH_MESSAGE_INTEGRATION_REMOVE_ERROR = `${FLASH_MESSAGE_PREFIX}integration_remove_error`; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + project: reads('model.project'), + + permissions: readOnly('globalState.permissions'), + + emptyData: equal('model.project.name', undefined), + showLoading: and('emptyData', 'model.loading'), + + actions: { + createIntegration({data, events, service}) { + const project = this.project; + + return this._mutateResource({ + mutation: integrationCreateQuery, + successMessage: FLASH_MESSAGE_INTEGRATION_ADD_SUCCESS, + errorMessage: FLASH_MESSAGE_INTEGRATION_ADD_ERROR, + variables: { + projectId: project.id, + data, + events, + service + } + }); + }, + + updateIntegration({integration, data, events, service}) { + return this._mutateResource({ + mutation: integrationUpdateQuery, + successMessage: FLASH_MESSAGE_INTEGRATION_UPDATE_SUCCESS, + errorMessage: FLASH_MESSAGE_INTEGRATION_UPDATE_ERROR, + variables: { + integrationId: integration.id, + data, + events, + service + } + }); + }, + + deleteIntegration(integration) { + return this._mutateResource({ + mutation: integrationDeleteQuery, + successMessage: FLASH_MESSAGE_INTEGRATION_REMOVE_SUCCESS, + errorMessage: FLASH_MESSAGE_INTEGRATION_REMOVE_ERROR, + variables: { + integrationId: integration.id + } + }); + } + }, + + _mutateResource({mutation, variables, successMessage, errorMessage}) { + return this.apolloMutate + .mutate({ + mutation, + variables, + refetchQueries: ['ProjectServiceIntegrations'] + }) + .then(() => this.flashMessages.success(this.i18n.t(successMessage))) + .catch(() => this.flashMessages.error(this.i18n.t(errorMessage))); + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/service-integrations/route.js b/webapp/app/pods/logged-in/project/edit/service-integrations/route.js new file mode 100644 index 00000000..5ec14b81 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/service-integrations/route.js @@ -0,0 +1,27 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectServiceIntegrationsQuery from 'accent-webapp/queries/project-service-integrations'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectServiceIntegrationsQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/edit/service-integrations/template.hbs b/webapp/app/pods/logged-in/project/edit/service-integrations/template.hbs new file mode 100644 index 00000000..47cf1eb0 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/service-integrations/template.hbs @@ -0,0 +1,13 @@ +{{#if showLoading}} + {{loading-content label=(t 'pods.project.edit.service_integrations.loading_content')}} +{{else}} + {{project-settings/back-link project=project}} + + {{project-settings/integrations + project=project + permissions=permissions + onCreateIntegration=(action 'createIntegration') + onUpdateIntegration=(action 'updateIntegration') + onDeleteIntegration=(action 'deleteIntegration') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/edit/template.hbs b/webapp/app/pods/logged-in/project/edit/template.hbs new file mode 100644 index 00000000..fe028224 --- /dev/null +++ b/webapp/app/pods/logged-in/project/edit/template.hbs @@ -0,0 +1,3 @@ +{{page-title text=(t 'pods.project.edit.title') icon='assets/gear.svg'}} + +{{outlet}} diff --git a/webapp/app/pods/logged-in/project/files/add-translations/controller.js b/webapp/app/pods/logged-in/project/files/add-translations/controller.js new file mode 100644 index 00000000..a26f83ea --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/add-translations/controller.js @@ -0,0 +1,73 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {readOnly, reads} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +const FLASH_MESSAGE_CREATE_SUCCESS = 'pods.document.merge.flash_messages.create_success'; +const FLASH_MESSAGE_CREATE_ERROR = 'pods.document.merge.flash_messages.create_error'; + +export default Controller.extend({ + peeker: service('peeker'), + merger: service('merger'), + globalState: service('global-state'), + i18n: service(), + flashMessages: service(), + + revisionOperations: null, + + permissions: readOnly('globalState.permissions'), + project: reads('model.projectModel.project'), + revisions: reads('project.revisions'), + documents: reads('model.fileModel.documents.entries'), + + document: computed('documents', 'model.fileId', function() { + return this.documents.find(({id}) => id === this.model.fileId); + }), + + actions: { + closeModal() { + this.send('onRefresh'); + this.transitionToRoute('logged-in.project.files', this.project.id); + }, + + cancelFile() { + this.set('revisionOperations', null); + }, + + peek({fileSource, documentFormat, revision, mergeType}) { + const file = fileSource; + const {project} = this; + const documentPath = this.document.path; + + return this.peeker + .merge({ + project, + revision, + file, + documentPath, + documentFormat, + mergeType + }) + .then(revisionOperations => this.set('revisionOperations', revisionOperations)); + }, + + merge({fileSource, revision, documentFormat, mergeType}) { + const file = fileSource; + const {project} = this; + const documentPath = this.document.path; + + return this.merger + .merge({ + project, + revision, + file, + documentPath, + documentFormat, + mergeType + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CREATE_SUCCESS))) + .then(() => this.send('closeModal')) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CREATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/add-translations/route.js b/webapp/app/pods/logged-in/project/files/add-translations/route.js new file mode 100644 index 00000000..65f6da36 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/add-translations/route.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model({fileId}) { + return RSVP.hash({ + projectModel: this.modelFor('logged-in.project'), + fileModel: this.modelFor('logged-in.project.files'), + fileId + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + revisionOperations: null + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/add-translations/template.hbs b/webapp/app/pods/logged-in/project/files/add-translations/template.hbs new file mode 100644 index 00000000..2a7951f1 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/add-translations/template.hbs @@ -0,0 +1,50 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{#project-file-operation}} + + +
    +
    + {{inline-svg '/assets/merge.svg' class='sectionType-icon'}} + {{t 'components.project_file_operations.merge'}} +
    + + {{document.path}} + +
    + {{t 'components.project_file_operations.document_format'}}: + {{document.format}} +
    +
    + +
    +
    + {{commit-file + permissions=permissions + revisions=revisions + canCommit=true + commitAction='merge' + peekAction='peek_merge' + commitButtonText=(t 'components.commit_file.merge_button') + onFileCancel=(action 'cancelFile') + onPeek=(action 'peek') + onCommit=(action 'merge') + }} +
    + +
    + {{#if revisionOperations.length}} + {{operations-peek revisionOperations=revisionOperations}} + {{else}} +

    Preview

    +
    + {{t 'components.project_file_operations.preview_text'}} +
    + {{/if}} +
    +
    + {{/project-file-operation}} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/files/controller.js b/webapp/app/pods/logged-in/project/files/controller.js new file mode 100644 index 00000000..cac26c36 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/controller.js @@ -0,0 +1,42 @@ +import {inject as service} from '@ember/service'; +import {not, readOnly, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; +import documentDeleteQuery from 'accent-webapp/queries/delete-document'; + +const FLASH_MESSAGE_DELETE_SUCCESS = 'pods.document.index.flash_messages.delete_success'; +const FLASH_MESSAGE_DELETE_ERROR = 'pods.document.index.flash_messages.delete_error'; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + page: 1, + + emptyEntries: not('model.documents', undefined), + permissions: readOnly('globalState.permissions'), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + deleteDocument(documentEntity) { + return this.apolloMutate + .mutate({ + mutation: documentDeleteQuery, + variables: { + documentId: documentEntity.id + } + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_DELETE_SUCCESS)); + this.send('onRefresh'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_DELETE_ERROR))); + }, + + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/export/controller.js b/webapp/app/pods/logged-in/project/files/export/controller.js new file mode 100644 index 00000000..65b59b51 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/export/controller.js @@ -0,0 +1,65 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads, empty} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + fileSaver: service('file-saver'), + globalState: service('global-state'), + + queryParams: ['revisionFilter', 'documentFormatFilter', 'orderByFilter'], + + exportLoading: true, + fileRender: null, + documentFormatFilter: null, + orderByFilter: null, + revisionFilter: null, + + project: reads('model.projectModel.project'), + revisions: reads('project.revisions'), + documents: reads('model.fileModel.documents.entries'), + showLoading: reads('model.fileModel.loading'), + exportButtonDisabled: empty('fileRender'), + + revision: computed('revisions', 'revisionFilter', function() { + if (!this.revisions) return; + + return this.revisions.find(({id}) => id === this.revisionFilter); + }), + + document: computed('documents', 'model.fileId', function() { + if (!this.documents) return; + + return this.documents.find(({id}) => id === this.model.fileId); + }), + + fileExtension: computed('documentFormatFilter', 'document.format', function() { + if (!this.globalState.documentFormats) return ''; + + const format = this.documentFormatFilter || this.document.format; + const documentFormatItem = this.globalState.documentFormats.find(({slug}) => slug === format); + if (!documentFormatItem) return ''; + + return documentFormatItem.extension; + }), + + actions: { + closeModal() { + this.transitionToRoute('logged-in.project.files', this.project.id); + }, + + onFileLoaded(content) { + this.set('fileRender', content); + this.set('exportLoading', false); + }, + + exportFile() { + const blob = new Blob([this.fileRender], { + type: 'charset=utf-8' + }); + const filename = `${this.document.path}.${this.fileExtension}`; + + this.fileSaver.saveAs(blob, filename); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/export/route.js b/webapp/app/pods/logged-in/project/files/export/route.js new file mode 100644 index 00000000..d06da683 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/export/route.js @@ -0,0 +1,37 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + exporter: service('exporter'), + + queryParams: { + revisionFilter: { + refreshModel: true + }, + documentFormatFilter: { + refreshModel: true + }, + orderByFilter: { + refreshModel: true + } + }, + + model({fileId}) { + return RSVP.hash({ + projectModel: this.modelFor('logged-in.project'), + fileModel: this.modelFor('logged-in.project.files'), + fileId + }); + }, + + resetController(controller, isExiting) { + controller.set('exportLoading', true); + + if (isExiting) { + controller.setProperties({ + fileRender: null + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/export/template.hbs b/webapp/app/pods/logged-in/project/files/export/template.hbs new file mode 100644 index 00000000..8658fdd0 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/export/template.hbs @@ -0,0 +1,55 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{#project-file-operation}} + + +
    +
    + {{inline-svg '/assets/export.svg' class='sectionType-icon'}} + {{t 'components.project_file_operations.export'}} +
    + + {{document.path}} + +
    + {{t 'components.project_file_operations.document_format'}}: + {{document.format}} +
    +
    + + {{revision-export-options + format=documentFormatFilter + orderBy=orderByFilter + revision=revisionFilter + revisions=revisions + onChangeRevision=(action (mut revisionFilter)) + onChangeFormat=(action (mut documentFormatFilter)) + onChangeOrderBy=(action (mut orderByFilter)) + }} + + {{#async-button + onClick=(action 'exportFile') + disabled=exportButtonDisabled + class='button button--filled render-export' + }} + {{t 'components.project_file_operations.export'}} + {{/async-button}} + + {{#if exportLoading}} + {{skeleton-ui/progress-line}} + {{/if}} + +
    {{file-export
    +      onFileLoaded=(action 'onFileLoaded')
    +      project=project
    +      revisions=revisions
    +      revision=revision
    +      document=document
    +      documentFormat=documentFormatFilter
    +      orderBy=orderByFilter
    +    }}
    + {{/project-file-operation}} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/files/new-sync/controller.js b/webapp/app/pods/logged-in/project/files/new-sync/controller.js new file mode 100644 index 00000000..2c80d6eb --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/new-sync/controller.js @@ -0,0 +1,59 @@ +import {inject as service} from '@ember/service'; +import {readOnly} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +const FLASH_MESSAGE_CREATE_SUCCESS = 'pods.document.sync.flash_messages.create_success'; +const FLASH_MESSAGE_CREATE_ERROR = 'pods.document.sync.flash_messages.create_error'; + +export default Controller.extend({ + peeker: service('peeker'), + syncer: service('syncer'), + i18n: service(), + flashMessages: service(), + globalState: service('global-state'), + + project: readOnly('model.project'), + revisions: readOnly('project.revisions'), + permissions: readOnly('globalState.permissions'), + + actions: { + closeModal() { + this.send('onRefresh'); + this.transitionToRoute('logged-in.project.files', this.model.project.id); + }, + + cancelFile() { + this.set('revisionOperations', null); + }, + + peek({fileSource, documentFormat, revision}) { + const file = fileSource; + const project = this.project; + const revisions = this.revisions; + const documentPath = file.name + Math.random(); + + return this.peeker + .sync({ + project, + revision, + revisions, + file, + documentPath, + documentFormat + }) + .then(revisionOperations => this.set('revisionOperations', revisionOperations)); + }, + + sync({fileSource, documentFormat, revision}) { + const file = fileSource; + const project = this.project; + const documentPath = file.name + Math.random(); + + return this.syncer + .sync({project, revision, file, documentPath, documentFormat}) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CREATE_SUCCESS))) + .then(() => this.send('closeModal')) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CREATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/new-sync/route.js b/webapp/app/pods/logged-in/project/files/new-sync/route.js new file mode 100644 index 00000000..da3b4dcc --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/new-sync/route.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + return this.modelFor('logged-in.project'); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + revisionOperations: null + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/new-sync/template.hbs b/webapp/app/pods/logged-in/project/files/new-sync/template.hbs new file mode 100644 index 00000000..534b13c1 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/new-sync/template.hbs @@ -0,0 +1,45 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{#project-file-operation}} + + +
    +
    + {{inline-svg '/assets/sync.svg' class='sectionType-icon'}} + {{t 'components.project_file_operations.sync'}} +
    + + {{t 'pods.document.new_sync.document_path'}} +
    + +
    +
    + {{commit-file + permissions=permissions + revisions=revisions + canCommit=true + commitAction='sync' + peekAction='peek_sync' + commitButtonText=(t 'components.commit_file.sync_button') + onFileCancel=(action 'cancelFile') + onPeek=(action 'peek') + onCommit=(action 'sync') + }} +
    + +
    + {{#if revisionOperations.length}} + {{operations-peek revisionOperations=revisionOperations}} + {{else}} +

    {{t 'components.project_file_operations.preview_title'}}

    +
    + {{t 'components.project_file_operations.preview_text'}} +
    + {{/if}} +
    +
    + {{/project-file-operation}} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/files/route.js b/webapp/app/pods/logged-in/project/files/route.js new file mode 100644 index 00000000..3f8781a3 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/route.js @@ -0,0 +1,37 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectDocumentsQuery from 'accent-webapp/queries/project-documents'; + +export default Route.extend(ApolloRoute, { + queryParams: { + page: { + refreshModel: true + } + }, + + model({page}, transition) { + if (page) page = parseInt(page, 10); + + return this.graphql(projectDocumentsQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + documents: get(data, 'viewer.project.documents') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + page + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/sync/controller.js b/webapp/app/pods/logged-in/project/files/sync/controller.js new file mode 100644 index 00000000..2a7eaaa1 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/sync/controller.js @@ -0,0 +1,64 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {readOnly, reads} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +const FLASH_MESSAGE_CREATE_SUCCESS = 'pods.document.sync.flash_messages.create_success'; +const FLASH_MESSAGE_CREATE_ERROR = 'pods.document.sync.flash_messages.create_error'; + +export default Controller.extend({ + peeker: service('peeker'), + syncer: service('syncer'), + globalState: service('global-state'), + i18n: service(), + flashMessages: service(), + + permissions: readOnly('globalState.permissions'), + project: reads('model.projectModel.project'), + revisions: reads('project.revisions'), + documents: reads('model.fileModel.documents.entries'), + + document: computed('documents', 'model.fileId', function() { + return this.documents.find(({id}) => id === this.model.fileId); + }), + + actions: { + closeModal() { + this.send('onRefresh'); + this.transitionToRoute('logged-in.project.files', this.project.id); + }, + + cancelFile() { + this.set('revisionOperations', null); + }, + + peek({fileSource, documentFormat, revision}) { + const file = fileSource; + const {project, revisions} = this; + const documentPath = this.document.path; + + return this.peeker + .sync({ + project, + revision, + revisions, + file, + documentPath, + documentFormat + }) + .then(revisionOperations => this.set('revisionOperations', revisionOperations)); + }, + + sync({fileSource, documentFormat, revision}) { + const file = fileSource; + const {project} = this; + const documentPath = this.document.path; + + return this.syncer + .sync({project, revision, file, documentPath, documentFormat}) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CREATE_SUCCESS))) + .then(() => this.send('closeModal')) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CREATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/sync/route.js b/webapp/app/pods/logged-in/project/files/sync/route.js new file mode 100644 index 00000000..65f6da36 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/sync/route.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model({fileId}) { + return RSVP.hash({ + projectModel: this.modelFor('logged-in.project'), + fileModel: this.modelFor('logged-in.project.files'), + fileId + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + revisionOperations: null + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/files/sync/template.hbs b/webapp/app/pods/logged-in/project/files/sync/template.hbs new file mode 100644 index 00000000..abd6d2b4 --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/sync/template.hbs @@ -0,0 +1,50 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{#project-file-operation}} + + +
    +
    + {{inline-svg '/assets/sync.svg' class='sectionType-icon'}} + {{t 'components.project_file_operations.sync'}} +
    + + {{document.path}} + +
    + {{t 'components.project_file_operations.document_format'}}: + {{document.format}} +
    +
    + +
    +
    + {{commit-file + permissions=permissions + revisions=revisions + canCommit=true + commitAction='sync' + peekAction='peek_sync' + commitButtonText=(t 'components.commit_file.sync_button') + onFileCancel=(action 'cancelFile') + onPeek=(action 'peek') + onCommit=(action 'sync') + }} +
    + +
    + {{#if revisionOperations.length}} + {{operations-peek revisionOperations=revisionOperations}} + {{else}} +

    {{t 'components.project_file_operations.preview_title'}}

    +
    + {{t 'components.project_file_operations.preview_text'}} +
    + {{/if}} +
    +
    + {{/project-file-operation}} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/files/template.hbs b/webapp/app/pods/logged-in/project/files/template.hbs new file mode 100644 index 00000000..8a31f72a --- /dev/null +++ b/webapp/app/pods/logged-in/project/files/template.hbs @@ -0,0 +1,30 @@ +{{page-title + text=(t 'pods.project.files.title') + icon='assets/file.svg' +}} + +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/documents-list}} +{{else}} + {{documents-list + permissions=permissions + documents=model.documents.entries + project=model.project + onDelete=(action 'deleteDocument') + }} + + {{#if (get permissions 'sync')}} + {{documents-add-button project=model.project}} + {{/if}} + + {{resource-pagination + meta=model.documents.meta + onSelectPage=(action 'selectPage') + }} + + {{outlet}} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/index/controller.js b/webapp/app/pods/logged-in/project/index/controller.js new file mode 100644 index 00000000..e7e388cb --- /dev/null +++ b/webapp/app/pods/logged-in/project/index/controller.js @@ -0,0 +1,50 @@ +import {inject as service} from '@ember/service'; +import {readOnly, reads, equal, and} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import Controller from '@ember/controller'; +import correctAllRevisionQuery from 'accent-webapp/queries/correct-all-revision'; +import uncorrectAllRevisionQuery from 'accent-webapp/queries/uncorrect-all-revision'; + +const FLASH_MESSAGE_REVISION_CORRECT_SUCCESS = 'pods.project.index.flash_messages.revision_correct_success'; +const FLASH_MESSAGE_REVISION_CORRECT_ERROR = 'pods.project.index.flash_messages.revision_correct_error'; +const FLASH_MESSAGE_REVISION_UNCORRECT_SUCCESS = 'pods.project.index.flash_messages.revision_uncorrect_success'; +const FLASH_MESSAGE_REVISION_UNCORRECT_ERROR = 'pods.project.index.flash_messages.revision_uncorrect_error'; + +export default Controller.extend({ + globalState: service('global-state'), + apolloMutate: service('apollo-mutate'), + flashMessages: service(), + i18n: service(), + + permissions: readOnly('globalState.permissions'), + project: reads('model.project'), + revisions: reads('project.revisions'), + emptyProject: equal('model.project', undefined), + showLoading: and('emptyProject', 'model.loading'), + + document: computed('project.documents.entries.[]', function() { + return this.project.documents.entries[0]; + }), + + actions: { + correctAllConflicts(revision) { + return this.apolloMutate + .mutate({ + mutation: correctAllRevisionQuery, + variables: {revisionId: revision.id} + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_REVISION_CORRECT_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_REVISION_CORRECT_ERROR))); + }, + + uncorrectAllConflicts(revision) { + return this.apolloMutate + .mutate({ + mutation: uncorrectAllRevisionQuery, + variables: {revisionId: revision.id} + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_REVISION_UNCORRECT_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_REVISION_UNCORRECT_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/index/route.js b/webapp/app/pods/logged-in/project/index/route.js new file mode 100644 index 00000000..dcf111a0 --- /dev/null +++ b/webapp/app/pods/logged-in/project/index/route.js @@ -0,0 +1,21 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectDashboardQuery from 'accent-webapp/queries/project-dashboard'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(projectDashboardQuery, { + props: data => ({ + project: get(data, 'viewer.project') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId + } + } + }); + } +}); diff --git a/webapp/app/pods/logged-in/project/index/template.hbs b/webapp/app/pods/logged-in/project/index/template.hbs new file mode 100644 index 00000000..70a90c50 --- /dev/null +++ b/webapp/app/pods/logged-in/project/index/template.hbs @@ -0,0 +1,17 @@ +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showLoading}} + {{loading-content label=(t 'pods.project.index.loading_content')}} +{{else}} + {{dashboard-revisions + document=document + project=project + activities=project.activities.entries + revisions=revisions + permissions=permissions + onCorrectAllConflicts=(action 'correctAllConflicts') + onUncorrectAllConflicts=(action 'uncorrectAllConflicts') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/revision/conflicts/controller.js b/webapp/app/pods/logged-in/project/revision/conflicts/controller.js new file mode 100644 index 00000000..a4950080 --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/conflicts/controller.js @@ -0,0 +1,101 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {readOnly, equal, empty, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; +import translationCorrectQuery from 'accent-webapp/queries/correct-translation'; +import correctAllRevisionQuery from 'accent-webapp/queries/correct-all-revision'; + +const FLASH_MESSAGE_REVISION_CORRECT_SUCCESS = 'pods.project.conflicts.flash_messages.revision_correct_success'; +const FLASH_MESSAGE_REVISION_CORRECT_ERROR = 'pods.project.conflicts.flash_messages.revision_correct_error'; +const FLASH_MESSAGE_CORRECT_SUCCESS = 'pods.project.conflicts.flash_messages.correct_success'; +const FLASH_MESSAGE_CORRECT_ERROR = 'pods.project.conflicts.flash_messages.correct_error'; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + queryParams: ['reference', 'page', 'query', 'document'], + + fullscreen: false, + query: '', + reference: null, + document: null, + page: 1, + + permissions: readOnly('globalState.permissions'), + revision: readOnly('model.project.revision'), + revisions: readOnly('model.revisionModel.project.revisions'), + + emptyEntries: equal('model.translations.entries', undefined), + emptyReference: empty('reference'), + emptyDocument: empty('document'), + emptyQuery: equal('query', ''), + + showSkeleton: and('emptyEntries', 'model.loading', 'emptyQuery', 'emptyReference', 'emptyDocument'), + showLoading: and('emptyEntries', 'model.loading'), + + referenceRevisions: computed('model.revisionId', 'revisions', function() { + if (!this.revisions) return []; + + return this.revisions.filter(revision => revision.id !== this.model.revisionId); + }), + + referenceRevision: computed('model.referenceRevisionId', 'revisions', function() { + if (!this.revisions || !this.model.referenceRevisionId) return; + + return this.revisions.find(revision => revision.id === this.model.referenceRevisionId); + }), + + actions: { + deactivateFullscreen() { + this.set('fullscreen', false); + }, + + correctConflict(conflict, text) { + return this.apolloMutate + .mutate({ + mutation: translationCorrectQuery, + variables: { + translationId: conflict.id, + text + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CORRECT_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CORRECT_ERROR))); + }, + + correctAllConflicts() { + return this.apolloMutate + .mutate({ + mutation: correctAllRevisionQuery, + variables: {revisionId: this.revision.id} + }) + .then(() => { + this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_REVISION_CORRECT_SUCCESS)); + return this.send('refresh'); + }) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_REVISION_CORRECT_ERROR))); + }, + + changeQuery(query) { + this.set('page', 1); + this.set('query', query); + }, + + changeReference(reference) { + this.set('reference', reference); + }, + + changeDocument(documentEntry) { + this.set('page', 1); + this.set('document', documentEntry); + }, + + selectPage(page) { + window.scrollTo(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/conflicts/route.js b/webapp/app/pods/logged-in/project/revision/conflicts/route.js new file mode 100644 index 00000000..13dec6ec --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/conflicts/route.js @@ -0,0 +1,81 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ResetScroll from 'accent-webapp/mixins/reset-scroll'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import translationsQuery from 'accent-webapp/queries/conflicts'; + +export default Route.extend(ResetScroll, ApolloRoute, { + queryParams: { + fullscreen: { + refreshModel: true + }, + query: { + refreshModel: true + }, + page: { + refreshModel: true + }, + reference: { + refreshModel: true + }, + document: { + refreshModel: true + } + }, + + model({query, page, reference, document}, transition) { + return this.graphql(translationsQuery, { + props: data => ({ + revisionId: transition.params['logged-in.project.revision'].revisionId, + referenceRevisionId: reference, + revisionModel: this.modelFor('logged-in.project.revision'), + documents: get(data, 'viewer.project.documents.entries'), + project: get(data, 'viewer.project'), + translations: get(data, 'viewer.project.revision.translations') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + revisionId: transition.params['logged-in.project.revision'].revisionId, + query, + page, + document, + reference + } + } + }); + }, + + renderTemplate(controller) { + if (controller.fullscreen) { + this.render('logged-in.project.revision.full-screen-conflicts', { + controller, + outlet: 'main' + }); + } else { + return this._super(...arguments); + } + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: 1 + }); + } + }, + + actions: { + onRevisionChange({revisionId}) { + const {project} = this.modelFor('logged-in.project'); + + this.transitionTo('logged-in.project.revision.conflicts', project.id, revisionId); + }, + + refresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/conflicts/template.hbs b/webapp/app/pods/logged-in/project/revision/conflicts/template.hbs new file mode 100644 index 00000000..55127d73 --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/conflicts/template.hbs @@ -0,0 +1,22 @@ +{{conflicts-page + project=model.project + revision=revision + translations=model.translations + isLoading=model.loading + showLoading=showLoading + fullscreen=fullscreen + document=document + permissions=permissions + documents=model.documents + showSkeleton=showSkeleton + query=query + reference=reference + referenceRevision=referenceRevision + referenceRevisions=referenceRevisions + onCorrect=(action 'correctConflict') + onCorrectAll=(action 'correctAllConflicts') + onSelectPage=(action 'selectPage') + onChangeDocument=(action 'changeDocument') + onChangeReference=(action 'changeReference') + onChangeQuery=(action 'changeQuery') +}} diff --git a/webapp/app/pods/logged-in/project/revision/controller.js b/webapp/app/pods/logged-in/project/revision/controller.js new file mode 100644 index 00000000..505f7d9b --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/controller.js @@ -0,0 +1,16 @@ +import {inject as service} from '@ember/service'; +import {readOnly} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + globalState: service('global-state'), + + revisions: readOnly('model.project.revisions'), + revision: readOnly('globalState.revision'), + + actions: { + selectRevision(revisionId) { + this.send('onRevisionChange', {revisionId}); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/full-screen-conflicts/template.hbs b/webapp/app/pods/logged-in/project/revision/full-screen-conflicts/template.hbs new file mode 100644 index 00000000..86bc30cd --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/full-screen-conflicts/template.hbs @@ -0,0 +1,23 @@ +{{#ember-wormhole to='modals'}} + {{conflicts-page + fullscreen=fullscreen + project=model.project + revision=revision + translations=model.translations + isLoading=model.loading + showLoading=showLoading + document=document + permissions=permissions + documents=model.documents + showSkeleton=showSkeleton + query=query + reference=reference + referenceRevision=referenceRevision + referenceRevisions=referenceRevisions + onCorrect=(action 'correctConflict') + onSelectPage=(action 'selectPage') + onChangeDocument=(action 'changeDocument') + onChangeReference=(action 'changeReference') + onChangeQuery=(action 'changeQuery') + }} +{{/ember-wormhole}} diff --git a/webapp/app/pods/logged-in/project/revision/route.js b/webapp/app/pods/logged-in/project/revision/route.js new file mode 100644 index 00000000..55910ce8 --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/route.js @@ -0,0 +1,12 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; + +export default Route.extend({ + globalState: service('global-state'), + + model({revisionId}) { + this.globalState.set('revision', revisionId); + + return this.modelFor('logged-in.project'); + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/template.hbs b/webapp/app/pods/logged-in/project/revision/template.hbs new file mode 100644 index 00000000..8c202c65 --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/template.hbs @@ -0,0 +1,7 @@ +{{revision-selector + revisions=revisions + revision=revision + onSelect=(action 'selectRevision') +}} + +{{outlet}} diff --git a/webapp/app/pods/logged-in/project/revision/translations/controller.js b/webapp/app/pods/logged-in/project/revision/translations/controller.js new file mode 100644 index 00000000..5ea16462 --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/translations/controller.js @@ -0,0 +1,61 @@ +import {inject as service} from '@ember/service'; +import {equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; +import translationUpdateQuery from 'accent-webapp/queries/update-translation'; + +const FLASH_MESSAGE_UPDATE_SUCCESS = 'pods.translation.edit.flash_messages.update_success'; +const FLASH_MESSAGE_UPDATE_ERROR = 'pods.translation.edit.flash_messages.update_error'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + i18n: service(), + flashMessages: service(), + + queryParams: ['query', 'page', 'document', 'version'], + + query: '', + page: 1, + document: null, + version: null, + + emptyEntries: equal('model.translations.entries', undefined), + emptyQuery: equal('query', ''), + showSkeleton: and('emptyEntries', 'model.loading', 'emptyQuery'), + showLoading: and('emptyEntries', 'model.loading'), + + actions: { + changeQuery(query) { + this.set('page', 1); + this.set('query', query); + }, + + changeVersion(versionId) { + this.set('page', 1); + this.set('version', versionId); + }, + + changeDocument(documentId) { + this.set('page', 1); + this.set('document', documentId); + }, + + selectPage(page) { + window.scrollTo(0, 0); + this.set('page', page); + }, + + updateText(translation, text) { + return this.apolloMutate + .mutate({ + mutation: translationUpdateQuery, + variables: { + translationId: translation.id, + text + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_UPDATE_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_UPDATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/translations/route.js b/webapp/app/pods/logged-in/project/revision/translations/route.js new file mode 100644 index 00000000..50ac0d9d --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/translations/route.js @@ -0,0 +1,62 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ResetScroll from 'accent-webapp/mixins/reset-scroll'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import translationsQuery from 'accent-webapp/queries/translations'; + +export default Route.extend(ResetScroll, ApolloRoute, { + queryParams: { + query: { + refreshModel: true + }, + page: { + refreshModel: true + }, + document: { + refreshModel: true + }, + version: { + refreshModel: true + } + }, + + model({query, page, document, version}, transition) { + return this.graphql(translationsQuery, { + props: data => ({ + revisionId: transition.params['logged-in.project.revision'].revisionId, + project: get(data, 'viewer.project'), + documents: get(data, 'viewer.project.documents.entries'), + versions: get(data, 'viewer.project.versions.entries'), + translations: get(data, 'viewer.project.revision.translations') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + revisionId: transition.params['logged-in.project.revision'].revisionId, + query, + page, + document, + version + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: 1 + }); + } + }, + + actions: { + onRevisionChange({revisionId}) { + const {project} = this.modelFor('logged-in.project'); + + this.transitionTo('logged-in.project.revision.translations', project.id, revisionId); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/revision/translations/template.hbs b/webapp/app/pods/logged-in/project/revision/translations/template.hbs new file mode 100644 index 00000000..ea6f065c --- /dev/null +++ b/webapp/app/pods/logged-in/project/revision/translations/template.hbs @@ -0,0 +1,34 @@ +{{translations-filter + query=query + document=document + documents=model.documents + version=version + versions=model.project.versions.entries + onChangeQuery=(action 'changeQuery') + onChangeDocument=(action 'changeDocument') + onChangeVersion=(action 'changeVersion') + meta=model.translations.meta +}} + +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/translations-list}} +{{else if showLoading}} + {{loading-content label=(t 'pods.project.translations.loading_content')}} +{{else}} + {{translations-list + project=model.project + revisionId=model.revisionId + translations=model.translations.entries + query=query + onUpdateText=(action 'updateText') + }} + + {{resource-pagination + meta=model.translations.meta + onSelectPage=(action 'selectPage') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/route.js b/webapp/app/pods/logged-in/project/route.js new file mode 100644 index 00000000..ccb8d93a --- /dev/null +++ b/webapp/app/pods/logged-in/project/route.js @@ -0,0 +1,41 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectQuery from 'accent-webapp/queries/project'; + +const props = data => { + if (!data.viewer || !data.viewer.project) return {permissions: []}; + + const permissions = data.viewer.project.viewerPermissions.reduce((memo, permission) => { + memo[permission] = true; + return memo; + }, {}); + + return {project: data.viewer.project, permissions, roles: data.roles, documentFormats: data.documentFormats}; +}; + +export default Route.extend(ApolloRoute, { + globalState: service('global-state'), + + model(params) { + return this.graphql(projectQuery, { + props, + options: { + variables: { + projectId: params.projectId + } + } + }); + }, + + actions: { + refreshModel() { + this.refresh(); + }, + + willTransition() { + this.set('globalState.isProjectNavigationListShowing', false); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/template.hbs b/webapp/app/pods/logged-in/project/template.hbs new file mode 100644 index 00000000..c390f659 --- /dev/null +++ b/webapp/app/pods/logged-in/project/template.hbs @@ -0,0 +1,37 @@ +{{projects-header + session=session + project=project +}} + +
    +
    + {{#if model.loading}} + {{skeleton-ui/project-navigation}} + {{else if showError}} + {{skeleton-ui/project-navigation}} + {{else}} + {{project-navigation + project=project + permissions=permissions + revisions=revisions + }} + {{/if}} +
    + +
    + {{#unless model.loading}} + {{phoenix-channel-listener project=project}} + {{/unless}} + + {{#if showError}} + {{error-section + status=(t 'pods.error.unauthorized.status') + title=(t 'pods.error.unauthorized.title') + text=(t 'pods.error.unauthorized.text') + isAuthenticated=false + }} + {{else}} + {{outlet}} + {{/if}} +
    +
    diff --git a/webapp/app/pods/logged-in/project/translation/activities/controller.js b/webapp/app/pods/logged-in/project/translation/activities/controller.js new file mode 100644 index 00000000..0fd397ab --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/activities/controller.js @@ -0,0 +1,22 @@ +import {inject as service} from '@ember/service'; +import {readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + globalState: service('global-state'), + + queryParams: ['page'], + + page: 1, + + permissions: readOnly('globalState.permissions'), + emptyEntries: equal('model.entries', undefined), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/activities/route.js b/webapp/app/pods/logged-in/project/translation/activities/route.js new file mode 100644 index 00000000..94e79e90 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/activities/route.js @@ -0,0 +1,47 @@ +import {get} from '@ember/object'; +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import translationActivitiesQuery from 'accent-webapp/queries/translation-activities'; + +export default Route.extend(ApolloRoute, { + apollo: service(), + + queryParams: { + page: { + refreshModel: true + } + }, + + model({page}, transition) { + return this.graphql(translationActivitiesQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + activities: get(data, 'viewer.project.translation.activities') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + translationId: transition.params['logged-in.project.translation'].translationId, + page + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: 1 + }); + } + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/activities/template.hbs b/webapp/app/pods/logged-in/project/translation/activities/template.hbs new file mode 100644 index 00000000..846c7790 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/activities/template.hbs @@ -0,0 +1,18 @@ +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/activities-list showTranslationLink=false}} +{{else}} + {{translation-activities-list + permissions=permissions + project=model.project + activities=model.activities.entries + }} + + {{resource-pagination + meta=model.activities.meta + onSelectPage=(action 'selectPage') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/translation/comments/controller.js b/webapp/app/pods/logged-in/project/translation/comments/controller.js new file mode 100644 index 00000000..20796242 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/comments/controller.js @@ -0,0 +1,64 @@ +import {inject as service} from '@ember/service'; +import {readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import commentCreateQuery from 'accent-webapp/queries/create-comment'; +import translationCommentsSubscriptionCreateQuery from 'accent-webapp/queries/create-translation-comments-subscription'; +import translationCommentsSubscriptionDeleteQuery from 'accent-webapp/queries/delete-translation-comments-subscription'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + + queryParams: ['page'], + + page: null, + + permissions: readOnly('globalState.permissions'), + emptyEntries: equal('model.comments', undefined), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + createComment(text) { + if (!text) text = ''; + const translation = this.model.translation; + + return this.apolloMutate.mutate({ + mutation: commentCreateQuery, + refetchQueries: ['TranslationComments'], + variables: { + translationId: translation.id, + text + } + }); + }, + + createSubscription(user) { + const translation = this.model.translation; + + return this.apolloMutate.mutate({ + mutation: translationCommentsSubscriptionCreateQuery, + refetchQueries: ['TranslationComments'], + variables: { + translationId: translation.id, + userId: user.id + } + }); + }, + + deleteSubscription(subscription) { + return this.apolloMutate.mutate({ + mutation: translationCommentsSubscriptionDeleteQuery, + refetchQueries: ['TranslationComments'], + variables: { + translationCommentsSubscripitionId: subscription.id + } + }); + }, + + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/comments/route.js b/webapp/app/pods/logged-in/project/translation/comments/route.js new file mode 100644 index 00000000..90d6eeec --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/comments/route.js @@ -0,0 +1,48 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import translationCommentsQuery from 'accent-webapp/queries/translation-comments'; + +export default Route.extend(ApolloRoute, { + queryParams: { + page: { + refreshModel: true + } + }, + + model({page}, transition) { + if (page) page = parseInt(page, 10); + + return this.graphql(translationCommentsQuery, { + props: data => ({ + translation: get(data, 'viewer.project.translation'), + comments: get(data, 'viewer.project.translation.comments'), + collaborators: get(data, 'viewer.project.collaborators'), + commentsSubscriptions: get(data, 'viewer.project.translation.commentsSubscriptions') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + translationId: transition.params['logged-in.project.translation'].translationId, + page + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + page: null + }); + } + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/comments/template.hbs b/webapp/app/pods/logged-in/project/translation/comments/template.hbs new file mode 100644 index 00000000..6cdb59f6 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/comments/template.hbs @@ -0,0 +1,19 @@ +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/translation-comments-list}} +{{else}} + {{translation-conversation + permissions=permissions + translation=model.translation + collaborators=model.collaborators + subscriptions=model.commentsSubscriptions + comments=model.comments + onCreateSubscription=(action 'createSubscription') + onDeleteSubscription=(action 'deleteSubscription') + onSubmit=(action 'createComment') + onSelectPage=(action 'selectPage') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/translation/controller.js b/webapp/app/pods/logged-in/project/translation/controller.js new file mode 100644 index 00000000..7bf8a7f2 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/controller.js @@ -0,0 +1,11 @@ +import {inject as service} from '@ember/service'; +import {readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + globalState: service('global-state'), + + permissions: readOnly('globalState.permissions'), + emptyTranslation: equal('model.translation', undefined), + showSkeleton: and('emptyTranslation', 'model.loading') +}); diff --git a/webapp/app/pods/logged-in/project/translation/index/controller.js b/webapp/app/pods/logged-in/project/translation/index/controller.js new file mode 100644 index 00000000..ab1e07d3 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/index/controller.js @@ -0,0 +1,70 @@ +import {inject as service} from '@ember/service'; +import {readOnly} from '@ember/object/computed'; +import Controller from '@ember/controller'; +import translationCorrectQuery from 'accent-webapp/queries/correct-translation'; +import translationUncorrectQuery from 'accent-webapp/queries/uncorrect-translation'; +import translationUpdateQuery from 'accent-webapp/queries/update-translation'; + +const FLASH_MESSAGE_CORRECT_SUCCESS = 'pods.translation.edit.flash_messages.correct_success'; +const FLASH_MESSAGE_CORRECT_ERROR = 'pods.translation.edit.flash_messages.correct_error'; + +const FLASH_MESSAGE_UNCORRECT_SUCCESS = 'pods.translation.edit.flash_messages.uncorrect_success'; +const FLASH_MESSAGE_UNCORRECT_ERROR = 'pods.translation.edit.flash_messages.uncorrect_error'; + +const FLASH_MESSAGE_UPDATE_SUCCESS = 'pods.translation.edit.flash_messages.update_success'; +const FLASH_MESSAGE_UPDATE_ERROR = 'pods.translation.edit.flash_messages.update_error'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + globalState: service('global-state'), + i18n: service(), + flashMessages: service(), + + permissions: readOnly('globalState.permissions'), + + actions: { + correctConflict(text) { + const conflict = this.model.translation; + + return this.apolloMutate + .mutate({ + mutation: translationCorrectQuery, + variables: { + translationId: conflict.id, + text + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CORRECT_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CORRECT_ERROR))); + }, + + uncorrectConflict() { + const conflict = this.model.translation; + + return this.apolloMutate + .mutate({ + mutation: translationUncorrectQuery, + variables: { + translationId: conflict.id + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_UNCORRECT_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_UNCORRECT_ERROR))); + }, + + updateText(text) { + const translation = this.model.translation; + + return this.apolloMutate + .mutate({ + mutation: translationUpdateQuery, + variables: { + translationId: translation.id, + text + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_UPDATE_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_UPDATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/index/route.js b/webapp/app/pods/logged-in/project/translation/index/route.js new file mode 100644 index 00000000..b82781b9 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/index/route.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + return this.modelFor('logged-in.project.translation'); + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/index/template.hbs b/webapp/app/pods/logged-in/project/translation/index/template.hbs new file mode 100644 index 00000000..28c72dd3 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/index/template.hbs @@ -0,0 +1,12 @@ +{{#unless translation.removed}} + {{translation-edit + translation=model.translation + project=model.project + permissions=permissions + onUpdateText=(action 'updateText') + onCorrectConflict=(action 'correctConflict') + onUncorrectConflict=(action 'uncorrectConflict') + }} +{{else}} + {{removed-translation-edit translation=model.translation}} +{{/unless}} diff --git a/webapp/app/pods/logged-in/project/translation/related-translations/controller.js b/webapp/app/pods/logged-in/project/translation/related-translations/controller.js new file mode 100644 index 00000000..9b9e50ca --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/related-translations/controller.js @@ -0,0 +1,34 @@ +import {inject as service} from '@ember/service'; +import {readOnly, equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import translationUpdateQuery from 'accent-webapp/queries/update-translation'; + +const FLASH_MESSAGE_UPDATE_SUCCESS = 'pods.translation.edit.flash_messages.update_success'; +const FLASH_MESSAGE_UPDATE_ERROR = 'pods.translation.edit.flash_messages.update_error'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + flashMessages: service(), + i18n: service(), + globalState: service('global-state'), + + permissions: readOnly('globalState.permissions'), + emptyEntries: equal('model.relatedTranslations', undefined), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + updateTranslation(translation, text) { + return this.apolloMutate + .mutate({ + mutation: translationUpdateQuery, + variables: { + translationId: translation.id, + text + } + }) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_UPDATE_SUCCESS))) + .catch(() => this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_UPDATE_ERROR))); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/related-translations/route.js b/webapp/app/pods/logged-in/project/translation/related-translations/route.js new file mode 100644 index 00000000..16a7ec99 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/related-translations/route.js @@ -0,0 +1,23 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import relatedTranslationsQuery from 'accent-webapp/queries/related-translations'; + +export default Route.extend(ApolloRoute, { + model(_params, transition) { + return this.graphql(relatedTranslationsQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + relatedTranslations: get(data, 'viewer.project.translation.relatedTranslations') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + translationId: transition.params['logged-in.project.translation'].translationId + } + } + }); + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/related-translations/template.hbs b/webapp/app/pods/logged-in/project/translation/related-translations/template.hbs new file mode 100644 index 00000000..45cbd6a2 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/related-translations/template.hbs @@ -0,0 +1,13 @@ +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/related-translations-list}} +{{else}} + {{related-translations-list + project=model.project + translations=model.relatedTranslations + onUpdateText=(action 'updateTranslation') + }} +{{/if}} diff --git a/webapp/app/pods/logged-in/project/translation/route.js b/webapp/app/pods/logged-in/project/translation/route.js new file mode 100644 index 00000000..73abfb97 --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/route.js @@ -0,0 +1,23 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import translationQuery from 'accent-webapp/queries/translation'; + +export default Route.extend(ApolloRoute, { + model({translationId}, transition) { + return this.graphql(translationQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + translation: get(data, 'viewer.project.translation') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + translationId + } + } + }); + } +}); diff --git a/webapp/app/pods/logged-in/project/translation/template.hbs b/webapp/app/pods/logged-in/project/translation/template.hbs new file mode 100644 index 00000000..57eec4ca --- /dev/null +++ b/webapp/app/pods/logged-in/project/translation/template.hbs @@ -0,0 +1,20 @@ +{{#if showSkeleton}} + {{skeleton-ui/translation-splash-title}} +{{else}} + {{translation-splash-title + project=model.project + translation=model.translation + }} +{{/if}} + +{{translation-navigation + project=model.project + permissions=permissions + translation=model.translation +}} + +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{outlet}} diff --git a/webapp/app/pods/logged-in/project/versions/controller.js b/webapp/app/pods/logged-in/project/versions/controller.js new file mode 100644 index 00000000..73b0463c --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/controller.js @@ -0,0 +1,22 @@ +import {inject as service} from '@ember/service'; +import {not, readOnly, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + i18n: service(), + flashMessages: service(), + globalState: service('global-state'), + + page: 1, + + emptyEntries: not('model.versions', undefined), + permissions: readOnly('globalState.permissions'), + showSkeleton: and('emptyEntries', 'model.loading'), + + actions: { + selectPage(page) { + window.scroll(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/edit/controller.js b/webapp/app/pods/logged-in/project/versions/edit/controller.js new file mode 100644 index 00000000..f3595f72 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/edit/controller.js @@ -0,0 +1,56 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import versionUpdateQuery from 'accent-webapp/queries/update-version'; + +const FLASH_MESSAGE_UPDATE_SUCCESS = 'pods.versions.edit.flash_messages.update_success'; +const FLASH_MESSAGE_UPDATE_ERROR = 'pods.versions.edit.flash_messages.update_error'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + i18n: service(), + flashMessages: service(), + + project: reads('model.projectModel.project'), + documents: reads('model.versionModel.documents.entries'), + versions: reads('model.versionModel.versions.entries'), + showLoading: reads('model.versionModel.loading'), + + version: computed('versions.[]', 'model.versionId', function() { + if (!this.versions) return; + + return this.versions.find(({id}) => id === this.model.versionId); + }), + + actions: { + closeModal() { + this.transitionToRoute('logged-in.project.versions', this.project.id); + }, + + update({name, tag}) { + this.set('error', false); + name = name || ''; + tag = tag || ''; + const id = this.version.id; + + return this.apolloMutate + .mutate({ + mutation: versionUpdateQuery, + variables: { + id, + name, + tag + } + }) + .then(() => this.transitionToRoute('logged-in.project.versions', this.project.id)) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_UPDATE_SUCCESS))) + .then(() => this.send('closeModal')) + .catch(() => { + this.set('error', true); + this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_UPDATE_ERROR)); + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/edit/route.js b/webapp/app/pods/logged-in/project/versions/edit/route.js new file mode 100644 index 00000000..bc79013d --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/edit/route.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model({versionId}) { + return RSVP.hash({ + projectModel: this.modelFor('logged-in.project'), + versionModel: this.modelFor('logged-in.project.versions'), + versionId + }); + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/edit/template.hbs b/webapp/app/pods/logged-in/project/versions/edit/template.hbs new file mode 100644 index 00000000..a3909678 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/edit/template.hbs @@ -0,0 +1,8 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{version-update-form + version=version + error=error + project=project + onUpdate=(action 'update') + }} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/versions/export/controller.js b/webapp/app/pods/logged-in/project/versions/export/controller.js new file mode 100644 index 00000000..26a7b2f2 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/export/controller.js @@ -0,0 +1,73 @@ +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {reads, empty} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + fileSaver: service('file-saver'), + globalState: service('global-state'), + + queryParams: ['revisionFilter', 'documentFilter', 'documentFormatFilter', 'orderByFilter'], + + exportLoading: true, + fileRender: null, + documentFormatFilter: null, + documentFilter: null, + orderByFilter: null, + revisionFilter: null, + + project: reads('model.projectModel.project'), + revisions: reads('project.revisions'), + documents: reads('model.versionModel.documents.entries'), + versions: reads('model.versionModel.versions.entries'), + showLoading: reads('model.versionModel.loading'), + exportButtonDisabled: empty('fileRender'), + + revision: computed('revisions.[]', 'revisionFilter', function() { + if (!this.revisions) return; + + return this.revisions.find(({id}) => id === this.revisionFilter); + }), + + version: computed('versions.[]', 'model.versionId', function() { + if (!this.versions) return; + + return this.versions.find(({id}) => id === this.model.versionId); + }), + + document: computed('documents.[]', 'documentFilter', function() { + if (!this.documents) return; + + return this.documents.find(({id}) => id === this.documentFilter) || this.documents[0]; + }), + + fileExtension: computed('documentFormatFilter', 'document.format', function() { + if (!this.globalState.documentFormats) return ''; + + const format = this.documentFormatFilter || this.document.format; + const documentFormatItem = this.globalState.documentFormats.find(({slug}) => slug === format); + if (!documentFormatItem) return ''; + + return documentFormatItem.extension; + }), + + actions: { + closeModal() { + this.transitionToRoute('logged-in.project.versions', this.project.id); + }, + + onFileLoaded(content) { + this.set('fileRender', content); + this.set('exportLoading', false); + }, + + exportFile() { + const blob = new Blob([this.fileRender], { + type: 'charset=utf-8' + }); + const filename = `${this.document.path}.${this.fileExtension}`; + + this.fileSaver.saveAs(blob, filename); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/export/route.js b/webapp/app/pods/logged-in/project/versions/export/route.js new file mode 100644 index 00000000..fd954d7b --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/export/route.js @@ -0,0 +1,40 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + exporter: service('exporter'), + + queryParams: { + revisionFilter: { + refreshModel: true + }, + documentFilter: { + refreshModel: true + }, + documentFormatFilter: { + refreshModel: true + }, + orderByFilter: { + refreshModel: true + } + }, + + model({versionId}) { + return RSVP.hash({ + projectModel: this.modelFor('logged-in.project'), + versionModel: this.modelFor('logged-in.project.versions'), + versionId + }); + }, + + resetController(controller, isExiting) { + controller.set('exportLoading', true); + + if (isExiting) { + controller.setProperties({ + fileRender: null + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/export/template.hbs b/webapp/app/pods/logged-in/project/versions/export/template.hbs new file mode 100644 index 00000000..5be80d29 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/export/template.hbs @@ -0,0 +1,64 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{#project-file-operation}} + + +
    +
    +
    + {{version.name}} + + {{inline-svg '/assets/tag.svg' class='versionTitle-tag-icon'}} + {{version.tag}} + +
    +
    + + {{document.path}} + +
    + {{t 'components.project_file_operations.document_format'}}: + {{document.format}} +
    +
    + + {{revision-export-options + format=documentFormatFilter + documents=documents + document=documentFilter + orderBy=orderByFilter + revision=revisionFilter + revisions=revisions + onChangeDocument=(action (mut documentFilter)) + onChangeRevision=(action (mut revisionFilter)) + onChangeFormat=(action (mut documentFormatFilter)) + onChangeOrderBy=(action (mut orderByFilter)) + }} + + {{#async-button + onClick=(action 'exportFile') + disabled=exportButtonDisabled + class='button button--filled render-export' + }} + {{t 'components.project_file_operations.export'}} + {{/async-button}} + + {{#if exportLoading}} + {{skeleton-ui/progress-line}} + {{/if}} + +
    {{file-export
    +        onFileLoaded=(action 'onFileLoaded')
    +        project=project
    +        revisions=revisions
    +        revision=revision
    +        document=document
    +        version=version.tag
    +        documentFormat=documentFormatFilter
    +        orderBy=orderByFilter
    +      }}
    + {{/project-file-operation}} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/versions/new/controller.js b/webapp/app/pods/logged-in/project/versions/new/controller.js new file mode 100644 index 00000000..50553549 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/new/controller.js @@ -0,0 +1,49 @@ +import {inject as service} from '@ember/service'; +import {readOnly} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +import versionCreateQuery from 'accent-webapp/queries/create-version'; + +const FLASH_MESSAGE_CREATE_SUCCESS = 'pods.versions.new.flash_messages.create_success'; +const FLASH_MESSAGE_CREATE_ERROR = 'pods.versions.new.flash_messages.create_error'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + session: service(), + i18n: service(), + flashMessages: service(), + + error: false, + project: readOnly('model.project'), + + actions: { + closeModal() { + this.send('onRefresh'); + this.transitionToRoute('logged-in.project.versions', this.project.id); + }, + + create({name, tag}) { + this.set('error', false); + name = name || ''; + tag = tag || ''; + const projectId = this.project.id; + + return this.apolloMutate + .mutate({ + mutation: versionCreateQuery, + variables: { + projectId, + name, + tag + } + }) + .then(() => this.transitionToRoute('logged-in.project.versions', this.project.id)) + .then(() => this.flashMessages.success(this.i18n.t(FLASH_MESSAGE_CREATE_SUCCESS))) + .then(() => this.send('closeModal')) + .catch(() => { + this.set('error', true); + this.flashMessages.error(this.i18n.t(FLASH_MESSAGE_CREATE_ERROR)); + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/new/route.js b/webapp/app/pods/logged-in/project/versions/new/route.js new file mode 100644 index 00000000..292d4cf8 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/new/route.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + return this.modelFor('logged-in.project'); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + error: false + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/new/template.hbs b/webapp/app/pods/logged-in/project/versions/new/template.hbs new file mode 100644 index 00000000..b45ef8b8 --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/new/template.hbs @@ -0,0 +1,7 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{version-create-form + error=error + project=model.project + onCreate=(action 'create') + }} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/project/versions/route.js b/webapp/app/pods/logged-in/project/versions/route.js new file mode 100644 index 00000000..9f370a4b --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/route.js @@ -0,0 +1,38 @@ +import {get} from '@ember/object'; +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; + +import projectVersionsQuery from 'accent-webapp/queries/project-versions'; + +export default Route.extend(ApolloRoute, { + queryParams: { + page: { + refreshModel: true + } + }, + + model({page}, transition) { + if (page) page = parseInt(page, 10); + + return this.graphql(projectVersionsQuery, { + props: data => ({ + project: get(data, 'viewer.project'), + versions: get(data, 'viewer.project.versions'), + documents: get(data, 'viewer.project.documents') + }), + options: { + fetchPolicy: 'cache-and-network', + variables: { + projectId: transition.params['logged-in.project'].projectId, + page + } + } + }); + }, + + actions: { + onRefresh() { + this.refresh(); + } + } +}); diff --git a/webapp/app/pods/logged-in/project/versions/template.hbs b/webapp/app/pods/logged-in/project/versions/template.hbs new file mode 100644 index 00000000..5401845f --- /dev/null +++ b/webapp/app/pods/logged-in/project/versions/template.hbs @@ -0,0 +1,29 @@ +{{page-title + text=(t 'pods.project.versions.title') + icon='assets/tag.svg' +}} + +{{#if model.loading}} + {{skeleton-ui/progress-line}} +{{/if}} + +{{#if showSkeleton}} + {{skeleton-ui/versions-list}} +{{else}} + {{versions-list + permissions=permissions + versions=model.versions.entries + project=model.project + }} + + {{#if (get permissions 'create_version')}} + {{versions-add-button project=model.project}} + {{/if}} + + {{resource-pagination + meta=model.versions.meta + onSelectPage=(action 'selectPage') + }} + + {{outlet}} +{{/if}} diff --git a/webapp/app/pods/logged-in/projects/controller.js b/webapp/app/pods/logged-in/projects/controller.js new file mode 100644 index 00000000..9ac52d5f --- /dev/null +++ b/webapp/app/pods/logged-in/projects/controller.js @@ -0,0 +1,26 @@ +import {inject as service} from '@ember/service'; +import {equal, and} from '@ember/object/computed'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + session: service('session'), + + queryParams: ['query', 'page'], + + query: '', + page: 1, + + emptyEntries: equal('model.projects.entries', undefined), + showLoading: and('emptyEntries', 'model.loading'), + + actions: { + changeQuery(query) { + this.set('query', query); + }, + + selectPage(page) { + window.scrollTo(0, 0); + this.set('page', page); + } + } +}); diff --git a/webapp/app/pods/logged-in/projects/new/controller.js b/webapp/app/pods/logged-in/projects/new/controller.js new file mode 100644 index 00000000..d62e0f1b --- /dev/null +++ b/webapp/app/pods/logged-in/projects/new/controller.js @@ -0,0 +1,33 @@ +import {inject as service} from '@ember/service'; +import Controller from '@ember/controller'; + +import projectCreateQuery from 'accent-webapp/queries/create-project'; + +export default Controller.extend({ + apolloMutate: service('apollo-mutate'), + session: service(), + + error: false, + + actions: { + closeModal() { + this.transitionToRoute('logged-in.projects'); + }, + + create({languageId, name}) { + this.set('error', false); + name = name || ''; + + return this.apolloMutate + .mutate({ + mutation: projectCreateQuery, + variables: { + name, + languageId + } + }) + .then(createProject => this.transitionToRoute('logged-in.project', createProject.project.id)) + .catch(() => this.set('error', true)); + } + } +}); diff --git a/webapp/app/pods/logged-in/projects/new/route.js b/webapp/app/pods/logged-in/projects/new/route.js new file mode 100644 index 00000000..987f04ef --- /dev/null +++ b/webapp/app/pods/logged-in/projects/new/route.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + return this.modelFor('logged-in.projects'); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + error: false + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/projects/new/template.hbs b/webapp/app/pods/logged-in/projects/new/template.hbs new file mode 100644 index 00000000..26512bc8 --- /dev/null +++ b/webapp/app/pods/logged-in/projects/new/template.hbs @@ -0,0 +1,7 @@ +{{#acc-modal onClose=(action 'closeModal')}} + {{project-create-form + error=error + languages=model.languages + onCreate=(action 'create') + }} +{{/acc-modal}} diff --git a/webapp/app/pods/logged-in/projects/route.js b/webapp/app/pods/logged-in/projects/route.js new file mode 100644 index 00000000..983721c5 --- /dev/null +++ b/webapp/app/pods/logged-in/projects/route.js @@ -0,0 +1,48 @@ +import Route from '@ember/routing/route'; +import ApolloRoute from 'accent-webapp/mixins/apollo-route'; +import AuthenticatedRoute from 'accent-webapp/mixins/authenticated-route'; + +import projectsQuery from 'accent-webapp/queries/projects'; + +export default Route.extend(ApolloRoute, AuthenticatedRoute, { + queryParams: { + query: { + refreshModel: true + }, + page: { + refreshModel: true + } + }, + + model({page, query}) { + return this.graphql(projectsQuery, { + props: data => { + if (!data.viewer) { + this.session.logout(); + return (window.location = '/'); + } + + return { + projects: data.viewer.projects, + languages: data.languages.entries + }; + }, + options: { + fetchPolicy: 'network-only', + variables: { + page, + query + } + } + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.setProperties({ + query: '', + page: 1 + }); + } + } +}); diff --git a/webapp/app/pods/logged-in/projects/template.hbs b/webapp/app/pods/logged-in/projects/template.hbs new file mode 100644 index 00000000..f5f4eefe --- /dev/null +++ b/webapp/app/pods/logged-in/projects/template.hbs @@ -0,0 +1,22 @@ +{{projects-header session=session}} + +{{projects-filters + query=query + onChangeQuery=(action 'changeQuery') +}} + +{{#if showLoading}} + {{loading-content label=(t 'pods.projects.loading_content')}} +{{else}} + {{projects-list + projects=model.projects.entries + query=query + }} + + {{resource-pagination + meta=model.projects.meta + onSelectPage=(action 'selectPage') + }} +{{/if}} + +{{outlet}} diff --git a/webapp/app/pods/logged-in/route.js b/webapp/app/pods/logged-in/route.js new file mode 100644 index 00000000..6149da13 --- /dev/null +++ b/webapp/app/pods/logged-in/route.js @@ -0,0 +1,12 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; +import Raven from 'npm:raven-js'; +import AuthenticatedRoute from 'accent-webapp/mixins/authenticated-route'; + +export default Route.extend(AuthenticatedRoute, { + session: service('session'), + + afterModel() { + Raven.setUserContext(this.session.credentials.user); + } +}); diff --git a/webapp/app/pods/logged-in/template.hbs b/webapp/app/pods/logged-in/template.hbs new file mode 100644 index 00000000..99150461 --- /dev/null +++ b/webapp/app/pods/logged-in/template.hbs @@ -0,0 +1,3 @@ +{{flash-messages-list flashMessages=flashMessages}} + +{{outlet}} diff --git a/webapp/app/pods/login/controller.js b/webapp/app/pods/login/controller.js new file mode 100644 index 00000000..7f5058d2 --- /dev/null +++ b/webapp/app/pods/login/controller.js @@ -0,0 +1,33 @@ +import {inject as service} from '@ember/service'; +import {computed} from '@ember/object'; +import Controller from '@ember/controller'; +import config from 'accent-webapp/config/environment'; + +export default Controller.extend({ + session: service('session'), + + username: '', + + googleLoginEnabled: computed(() => config.GOOGLE_LOGIN_ENABLED), + dummyLoginEnabled: computed(() => config.DUMMY_LOGIN_ENABLED), + + actions: { + dummyLogin(token) { + this._login({token, provider: 'dummy'}); + }, + + googleLogin(googleUser) { + const token = googleUser.getAuthResponse().id_token; + + this._login({token, provider: 'google'}); + } + }, + + _login({token, provider}) { + this.session.login({token, provider}).then(data => { + if (data && data.token) { + this.transitionToRoute('logged-in.projects'); + } + }); + } +}); diff --git a/webapp/app/pods/login/route.js b/webapp/app/pods/login/route.js new file mode 100644 index 00000000..9227ba4c --- /dev/null +++ b/webapp/app/pods/login/route.js @@ -0,0 +1,12 @@ +import {inject as service} from '@ember/service'; +import Route from '@ember/routing/route'; + +export default Route.extend({ + session: service('session'), + + redirect() { + if (this.session.isAuthenticated) { + this.transitionTo('logged-in.projects'); + } + } +}); diff --git a/webapp/app/pods/login/template.hbs b/webapp/app/pods/login/template.hbs new file mode 100644 index 00000000..03e4afa7 --- /dev/null +++ b/webapp/app/pods/login/template.hbs @@ -0,0 +1,9 @@ +{{projects-header session=session}} + +{{#if googleLoginEnabled}} + {{google-login-form onGoogleLogin=(action 'googleLogin')}} +{{/if}} + +{{#if dummyLoginEnabled}} + {{dummy-login-form onDummyLogin=(action 'dummyLogin')}} +{{/if}} diff --git a/webapp/app/pods/not-found/controller.js b/webapp/app/pods/not-found/controller.js new file mode 100644 index 00000000..909553c8 --- /dev/null +++ b/webapp/app/pods/not-found/controller.js @@ -0,0 +1,13 @@ +import {inject as service} from '@ember/service'; +import Controller from '@ember/controller'; + +export default Controller.extend({ + session: service('session'), + + actions: { + logout() { + this.session.logout(); + window.location = '/'; + } + } +}); diff --git a/webapp/app/pods/not-found/template.hbs b/webapp/app/pods/not-found/template.hbs new file mode 100644 index 00000000..bfdfd540 --- /dev/null +++ b/webapp/app/pods/not-found/template.hbs @@ -0,0 +1,7 @@ +{{error-section + status=(t 'pods.error.not_found.status') + title=(t 'pods.error.not_found.title') + text=(t 'pods.error.not_found.text') + onLogout=(action 'logout') + isAuthenticated=session.isAuthenticated +}} diff --git a/webapp/app/pods/phoenix/service.js b/webapp/app/pods/phoenix/service.js new file mode 100644 index 00000000..80aff8f0 --- /dev/null +++ b/webapp/app/pods/phoenix/service.js @@ -0,0 +1,94 @@ +import Service, {inject as service} from '@ember/service'; +import RSVP from 'rsvp'; +import config from 'accent-webapp/config/environment'; +import {Socket} from 'accent-webapp/utils/phoenix'; + +export default Service.extend({ + i18n: service(), + flashMessages: service(), + + socket({token}) { + const socket = new Socket(`${config.API.WS_HOST}/socket`, { + params: {token} + }); + + socket.connect(); + return socket; + }, + + leaveChannel(channel) { + if (channel) return channel.leave(); + }, + + getChannel(channelName, {token}) { + return new RSVP.Promise(resolve => { + const socket = this.socket({token}); + + resolve(socket.channel(channelName)); + }); + }, + + joinChannel(channel) { + return new RSVP.Promise((resolve, reject) => { + channel + .join() + .receive('ok', () => resolve(channel)) + .receive('error', reason => reject(reason)); + }); + }, + + bindChannelEvents(channel, currentUserId) { + return new RSVP.Promise(resolve => { + /* eslint-disable camelcase */ + const events = { + sync: this._handleSync, + create_collaborator: this._handleCreateCollaborator, + create_comment: this._handleCreateComment + }; + /* eslint-enable camelcase */ + + Object.keys(events).forEach(eventId => { + channel.on(eventId, payload => + this._handleEvent(events[eventId].bind(this), { + payload, + currentUserId + }) + ); + }); + + resolve(channel); + }); + }, + + _handleEvent(func, {payload, currentUserId}) { + if (payload.user && payload.user.id !== currentUserId) return func({payload}); + }, + + _handleSync({payload}) { + /* eslint camelcase:0 */ + this._showFlashMessage('sync', { + user: payload.user.name, + documentPath: payload.document_path + }); + /* eslint camelcase:1 */ + }, + + _handleCreateCollaborator({payload}) { + this._showFlashMessage('create_collaborator', { + user: payload.user.name, + collaboratorEmail: payload.collaborator.email + }); + }, + + _handleCreateComment({payload}) { + this._showFlashMessage('create_comment', { + user: payload.user.name, + commentText: payload.comment.text, + translationKey: payload.comment.translation.key + }); + }, + + _showFlashMessage(event, options) { + this.flashMessages.socket(this.i18n.t(`addon.channel.handle_in.${event}`, options)); + } +}); diff --git a/webapp/app/queries/activity-activities.graphql b/webapp/app/queries/activity-activities.graphql new file mode 100644 index 00000000..0bf92432 --- /dev/null +++ b/webapp/app/queries/activity-activities.graphql @@ -0,0 +1,67 @@ +query ActivityActivities($projectId: ID!, $activityId: ID!, $page: Int) { + viewer { + project(id: $projectId) { + id + + activity(id: $activityId) { + id + + operations(page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + + entries { + ...activitiesActivityFields + + revision { + id + language { + id + name + } + } + } + } + } + } + } +} + +fragment activitiesActivityFields on Activity { + id + action + text + insertedAt + + user { + id + fullname + pictureUrl + isBot + } + + translation { + id + key + } + + stats { + action + count + } + + document { + id + path + } + + version { + id + tag + } +} diff --git a/webapp/app/queries/conflicts.graphql b/webapp/app/queries/conflicts.graphql new file mode 100644 index 00000000..da22aba2 --- /dev/null +++ b/webapp/app/queries/conflicts.graphql @@ -0,0 +1,40 @@ +query Conflicts($projectId: ID! $revisionId: ID!, $query: String, $page: Int, $reference: ID, $document: ID) { + viewer { + project(id: $projectId) { + id + + documents { + entries { + id + path + format + } + } + + revision(id: $revisionId) { + id + translations(query: $query, page: $page, document: $document, referenceRevision: $reference, isConflicted: true) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + key + conflictedText + correctedText + valueType + relatedTranslation { + id + correctedText + updatedAt + } + } + } + } + } + } +} diff --git a/webapp/app/queries/correct-all-revision.graphql b/webapp/app/queries/correct-all-revision.graphql new file mode 100644 index 00000000..a4e4e11b --- /dev/null +++ b/webapp/app/queries/correct-all-revision.graphql @@ -0,0 +1,11 @@ +mutation CorrectAll($revisionId: ID!) { + correctAllRevision(id: $revisionId) { + revision { + id + conflictsCount + reviewedCount + } + + errors + } +} diff --git a/webapp/app/queries/correct-translation.graphql b/webapp/app/queries/correct-translation.graphql new file mode 100644 index 00000000..7d44e8bd --- /dev/null +++ b/webapp/app/queries/correct-translation.graphql @@ -0,0 +1,13 @@ +mutation TranslationCorrect($translationId: ID!, $text: String!) { + correctTranslation(id: $translationId, text: $text) { + translation { + id + correctedText + conflictedText + isConflicted + updatedAt + } + + errors + } +} diff --git a/webapp/app/queries/create-collaborator.graphql b/webapp/app/queries/create-collaborator.graphql new file mode 100644 index 00000000..861d22ef --- /dev/null +++ b/webapp/app/queries/create-collaborator.graphql @@ -0,0 +1,9 @@ +mutation CollaboratorCreate($role: Role!, $email: String!, $projectId: ID!) { + createCollaborator(role: $role, email: $email, projectId: $projectId) { + collaborator { + id + } + + errors + } +} diff --git a/webapp/app/queries/create-comment.graphql b/webapp/app/queries/create-comment.graphql new file mode 100644 index 00000000..6cfb9f8a --- /dev/null +++ b/webapp/app/queries/create-comment.graphql @@ -0,0 +1,10 @@ +mutation CommentCreate($translationId: ID!, $text: String!) { + createComment(id: $translationId, text: $text) { + comment { + id + text + } + + errors + } +} diff --git a/webapp/app/queries/create-integration.graphql b/webapp/app/queries/create-integration.graphql new file mode 100644 index 00000000..52541ce9 --- /dev/null +++ b/webapp/app/queries/create-integration.graphql @@ -0,0 +1,9 @@ +mutation IntegrationCreate($events: [IntegrationEvent!]!, $service: IntegrationService!, $projectId: ID!, $data: IntegrationDataInput!) { + createIntegration(events: $events, service: $service, projectId: $projectId, data: $data) { + integration { + id + } + + errors + } +} diff --git a/webapp/app/queries/create-project.graphql b/webapp/app/queries/create-project.graphql new file mode 100644 index 00000000..d55f1d47 --- /dev/null +++ b/webapp/app/queries/create-project.graphql @@ -0,0 +1,9 @@ +mutation ProjectCreate($name: String!, $languageId: ID!) { + createProject(name: $name, languageId: $languageId) { + project { + id + } + + errors + } +} diff --git a/webapp/app/queries/create-revision.graphql b/webapp/app/queries/create-revision.graphql new file mode 100644 index 00000000..ebef7e98 --- /dev/null +++ b/webapp/app/queries/create-revision.graphql @@ -0,0 +1,9 @@ +mutation RevisionCreate($projectId: ID!, $languageId: ID!) { + createRevision(projectId: $projectId, languageId: $languageId) { + revision { + id + } + + errors + } +} diff --git a/webapp/app/queries/create-translation-comments-subscription.graphql b/webapp/app/queries/create-translation-comments-subscription.graphql new file mode 100644 index 00000000..a4ee2e30 --- /dev/null +++ b/webapp/app/queries/create-translation-comments-subscription.graphql @@ -0,0 +1,9 @@ +mutation TranslationCommentsSubscriptionCreate($translationId: ID!, $userId: ID!) { + createTranslationCommentsSubscription(translationId: $translationId, userId: $userId) { + translationCommentsSubscription { + id + } + + errors + } +} diff --git a/webapp/app/queries/create-version.graphql b/webapp/app/queries/create-version.graphql new file mode 100644 index 00000000..572eea00 --- /dev/null +++ b/webapp/app/queries/create-version.graphql @@ -0,0 +1,9 @@ +mutation VersionCreate($name: String!, $tag: String!, $projectId: ID!) { + createVersion(name: $name, tag: $tag, projectId: $projectId) { + version { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-collaborator.graphql b/webapp/app/queries/delete-collaborator.graphql new file mode 100644 index 00000000..9cadb7cd --- /dev/null +++ b/webapp/app/queries/delete-collaborator.graphql @@ -0,0 +1,9 @@ +mutation CollaboratorDelete($collaboratorId: ID!) { + deleteCollaborator(id: $collaboratorId) { + collaborator { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-document.graphql b/webapp/app/queries/delete-document.graphql new file mode 100644 index 00000000..24aee9fb --- /dev/null +++ b/webapp/app/queries/delete-document.graphql @@ -0,0 +1,9 @@ +mutation DocumentDelete($documentId: ID!) { + deleteDocument(id: $documentId) { + document { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-integration.graphql b/webapp/app/queries/delete-integration.graphql new file mode 100644 index 00000000..925099aa --- /dev/null +++ b/webapp/app/queries/delete-integration.graphql @@ -0,0 +1,9 @@ +mutation IntegrationDelete($integrationId: ID!) { + deleteIntegration(id: $integrationId) { + integration { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-project.graphql b/webapp/app/queries/delete-project.graphql new file mode 100644 index 00000000..fea8d9bb --- /dev/null +++ b/webapp/app/queries/delete-project.graphql @@ -0,0 +1,9 @@ +mutation ProjectDelete($projectId: ID!) { + deleteProject(id: $projectId) { + project { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-revision.graphql b/webapp/app/queries/delete-revision.graphql new file mode 100644 index 00000000..0a7bddcd --- /dev/null +++ b/webapp/app/queries/delete-revision.graphql @@ -0,0 +1,9 @@ +mutation RevisionDelete($revisionId: ID!) { + deleteRevision(id: $revisionId) { + revision { + id + } + + errors + } +} diff --git a/webapp/app/queries/delete-translation-comments-subscription.graphql b/webapp/app/queries/delete-translation-comments-subscription.graphql new file mode 100644 index 00000000..3c09afbc --- /dev/null +++ b/webapp/app/queries/delete-translation-comments-subscription.graphql @@ -0,0 +1,9 @@ +mutation TranslationCommentsSubscriptionDelete($translationCommentsSubscripitionId: ID!) { + deleteTranslationCommentsSubscription(id: $translationCommentsSubscripitionId) { + translationCommentsSubscription { + id + } + + errors + } +} diff --git a/webapp/app/queries/languages-search.graphql b/webapp/app/queries/languages-search.graphql new file mode 100644 index 00000000..3bd9370e --- /dev/null +++ b/webapp/app/queries/languages-search.graphql @@ -0,0 +1,9 @@ +query LanguagesSearch($query: String!) { + languages(query: $query) { + entries { + id + name + slug + } + } +} diff --git a/webapp/app/queries/project-activities.graphql b/webapp/app/queries/project-activities.graphql new file mode 100644 index 00000000..1d829f81 --- /dev/null +++ b/webapp/app/queries/project-activities.graphql @@ -0,0 +1,102 @@ +query ProjectActivities($projectId: ID!, $page: Int, $userId: String, $isBatch: Boolean, $action: String) { + viewer { + project(id: $projectId) { + id + isFileOperationsLocked + + collaborators { + id + isPending + role + + user { + id + fullname + email + } + } + + activities(page: $page, userId: $userId, isBatch: $isBatch, action: $action) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + + entries { + id + action + insertedAt + updatedAt + isBatch + isRollbacked + activityType + valueType + text + + stats { + action + count + } + + user { + id + fullname + pictureUrl + isBot + } + + document { + id + path + } + + translation { + id + key + correctedText + isRemoved + } + + revision { + id + language { + id + name + } + } + + version { + id + tag + } + + rollbackedOperation { + id + action + insertedAt + text + + user { + id + fullname + isBot + } + + translation { + id + key + } + + document { + id + path + } + } + } + } + } + } +} diff --git a/webapp/app/queries/project-activity.graphql b/webapp/app/queries/project-activity.graphql new file mode 100644 index 00000000..8649e789 --- /dev/null +++ b/webapp/app/queries/project-activity.graphql @@ -0,0 +1,90 @@ +query ProjectActivity($projectId: ID!, $activityId: ID!) { + viewer { + project(id: $projectId) { + id + + activity(id: $activityId) { + ...activityFields + + isBatch + isRollbacked + activityType + + document { + id + path + format + } + + previousTranslation { + proposedText + text + isConflicted + isRemoved + valueType + } + + translation { + id + key + correctedText + isConflicted + isRemoved + } + + version { + id + tag + } + + batchOperation { + ...activityFields + } + + rollbackedOperation { + ...activityFields + } + + rollbackOperation { + ...activityFields + } + } + } + } +} + +fragment activityFields on Activity { + id + action + text + insertedAt + updatedAt + valueType + + user { + id + fullname + pictureUrl + isBot + } + + translation { + id + key + } + + stats { + action + count + } + + document { + id + path + } + + version { + id + tag + } +} diff --git a/webapp/app/queries/project-api-token.graphql b/webapp/app/queries/project-api-token.graphql new file mode 100644 index 00000000..fc1120a9 --- /dev/null +++ b/webapp/app/queries/project-api-token.graphql @@ -0,0 +1,9 @@ +query ProjectApiToken($projectId: ID!) { + viewer { + project(id: $projectId) { + id + name + accessToken + } + } +} diff --git a/webapp/app/queries/project-collaborators.graphql b/webapp/app/queries/project-collaborators.graphql new file mode 100644 index 00000000..c318a4fd --- /dev/null +++ b/webapp/app/queries/project-collaborators.graphql @@ -0,0 +1,29 @@ +query ProjectCollaborators($projectId: ID!) { + viewer { + project(id: $projectId) { + id + name + + collaborators { + id + isPending + email + role + insertedAt + + assigner { + id + fullname + } + + user { + isBot + id + fullname + pictureUrl + email + } + } + } + } +} diff --git a/webapp/app/queries/project-comments.graphql b/webapp/app/queries/project-comments.graphql new file mode 100644 index 00000000..98b3c594 --- /dev/null +++ b/webapp/app/queries/project-comments.graphql @@ -0,0 +1,38 @@ +query ProjectComments($projectId: ID!, $page: Int) { + viewer { + project(id: $projectId) { + id + comments(page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + text + insertedAt + user { + id + email + fullname + pictureUrl + } + translation { + id + key + revision { + id + language { + id + name + } + } + } + } + } + } + } +} diff --git a/webapp/app/queries/project-dashboard.graphql b/webapp/app/queries/project-dashboard.graphql new file mode 100644 index 00000000..4ff80ceb --- /dev/null +++ b/webapp/app/queries/project-dashboard.graphql @@ -0,0 +1,98 @@ +query Dashboard($projectId: ID!) { + viewer { + project(id: $projectId) { + id + name + lastSyncedAt + + documents { + entries { + id + } + } + + revisions { + id + conflictsCount + reviewedCount + translationsCount + isMaster + language { + id + name + } + } + + activities(pageSize: 7) { + entries { + id + action + insertedAt + isBatch + isRollbacked + activityType + text + + stats { + action + count + } + + user { + id + pictureUrl + fullname + isBot + } + + document { + id + path + } + + translation { + id + key + correctedText + isRemoved + } + + revision { + id + language { + id + name + } + } + + version { + id + tag + } + + rollbackedOperation { + id + action + text + + user { + id + fullname + isBot + } + + translation { + id + key + } + + document { + id + path + } + } + } + } + } + } +} diff --git a/webapp/app/queries/project-documents.graphql b/webapp/app/queries/project-documents.graphql new file mode 100644 index 00000000..fcfc98da --- /dev/null +++ b/webapp/app/queries/project-documents.graphql @@ -0,0 +1,27 @@ +query ProjectDocuments($projectId: ID!, $page: Int) { + viewer { + project(id: $projectId) { + id + + revisions { + id + } + + documents(page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + path + format + translationsCount + } + } + } + } +} diff --git a/webapp/app/queries/project-edit.graphql b/webapp/app/queries/project-edit.graphql new file mode 100644 index 00000000..8b8394cb --- /dev/null +++ b/webapp/app/queries/project-edit.graphql @@ -0,0 +1,9 @@ +query ProjectEdit($projectId: ID!) { + viewer { + project(id: $projectId) { + id + name + isFileOperationsLocked + } + } +} diff --git a/webapp/app/queries/project-new-language.graphql b/webapp/app/queries/project-new-language.graphql new file mode 100644 index 00000000..3b97a591 --- /dev/null +++ b/webapp/app/queries/project-new-language.graphql @@ -0,0 +1,26 @@ +query ProjectNewLanguage ($projectId: ID!) { + languages { + entries { + id + name + slug + } + } + + viewer { + project(id: $projectId) { + id + revisions { + id + isMaster + insertedAt + + language { + id + slug + name + } + } + } + } +} diff --git a/webapp/app/queries/project-service-integrations.graphql b/webapp/app/queries/project-service-integrations.graphql new file mode 100644 index 00000000..5d99ee4d --- /dev/null +++ b/webapp/app/queries/project-service-integrations.graphql @@ -0,0 +1,18 @@ +query ProjectServiceIntegrations($projectId: ID!) { + viewer { + project(id: $projectId) { + id + name + + integrations { + id + service + events + data { + id + url + } + } + } + } +} diff --git a/webapp/app/queries/project-versions.graphql b/webapp/app/queries/project-versions.graphql new file mode 100644 index 00000000..c3052d6c --- /dev/null +++ b/webapp/app/queries/project-versions.graphql @@ -0,0 +1,37 @@ +query ProjectVersions($projectId: ID!, $page: Int) { + viewer { + project(id: $projectId) { + id + + documents { + entries { + id + path + format + translationsCount + } + } + + versions(page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + name + tag + insertedAt + + user { + id + fullname + } + } + } + } + } +} diff --git a/webapp/app/queries/project.graphql b/webapp/app/queries/project.graphql new file mode 100644 index 00000000..c9119c5b --- /dev/null +++ b/webapp/app/queries/project.graphql @@ -0,0 +1,39 @@ +query Project($projectId: ID!) { + roles { + slug + } + + documentFormats { + slug + name + extension + } + + viewer { + project(id: $projectId) { + id + name + + viewerPermissions + + documents { + entries { + id + path + format + } + } + + revisions { + id + isMaster + + language { + id + slug + name + } + } + } + } +} diff --git a/webapp/app/queries/projects.graphql b/webapp/app/queries/projects.graphql new file mode 100644 index 00000000..4168f0a2 --- /dev/null +++ b/webapp/app/queries/projects.graphql @@ -0,0 +1,30 @@ +query Projects($query: String, $page: Int) { + languages { + entries { + id + name + slug + } + } + + viewer { + projects(query: $query, page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + name + lastSyncedAt + language { + id + name + } + } + } + } +} diff --git a/webapp/app/queries/promote-master-revision.graphql b/webapp/app/queries/promote-master-revision.graphql new file mode 100644 index 00000000..9e14cbd2 --- /dev/null +++ b/webapp/app/queries/promote-master-revision.graphql @@ -0,0 +1,9 @@ +mutation RevisionMasterPromote($revisionId: ID!) { + promoteRevisionMaster(id: $revisionId) { + revision { + id + } + + errors + } +} diff --git a/webapp/app/queries/related-translations.graphql b/webapp/app/queries/related-translations.graphql new file mode 100644 index 00000000..8830b724 --- /dev/null +++ b/webapp/app/queries/related-translations.graphql @@ -0,0 +1,27 @@ +query RelatedTranslations($projectId: ID!, $translationId: ID!) { + viewer { + project(id: $projectId) { + id + translation(id: $translationId) { + id + relatedTranslations { + id + key + correctedText + isConflicted + isRemoved + updatedAt + + revision { + id + + language { + id + name + } + } + } + } + } + } +} diff --git a/webapp/app/queries/rollback-operation.graphql b/webapp/app/queries/rollback-operation.graphql new file mode 100644 index 00000000..cd0a1c3f --- /dev/null +++ b/webapp/app/queries/rollback-operation.graphql @@ -0,0 +1,6 @@ +mutation OperationRollback($operationId: ID!) { + rollbackOperation(id: $operationId) { + operation + errors + } +} diff --git a/webapp/app/queries/translation-activities.graphql b/webapp/app/queries/translation-activities.graphql new file mode 100644 index 00000000..6759c415 --- /dev/null +++ b/webapp/app/queries/translation-activities.graphql @@ -0,0 +1,77 @@ +query TranslationActivities($projectId: ID!, $translationId: ID!, $page: Int, $action: String) { + viewer { + project(id: $projectId) { + id + isFileOperationsLocked + + translation(id: $translationId) { + id + activities(page: $page, action: $action) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + action + insertedAt + isBatch + isRollbacked + text + valueType + + user { + id + fullname + pictureUrl + isBot + } + + document { + id + path + format + } + + translation { + id + correctedText + } + + version { + id + tag + } + + rollbackedOperation { + id + insertedAt + action + text + + user { + id + fullname + pictureUrl + isBot + } + + translation { + id + key + } + + document { + id + path + } + } + } + } + } + } + } +} diff --git a/webapp/app/queries/translation-comments.graphql b/webapp/app/queries/translation-comments.graphql new file mode 100644 index 00000000..ced99a78 --- /dev/null +++ b/webapp/app/queries/translation-comments.graphql @@ -0,0 +1,57 @@ +query TranslationComments($projectId: ID!, $translationId: ID!, $page: Int) { + viewer { + project(id: $projectId) { + id + + collaborators { + id + email + isPending + role + + user { + id + fullname + email + } + } + + translation(id: $translationId) { + id + commentsCount + isRemoved + + commentsSubscriptions { + id + user { + id + email + fullname + } + } + + comments(page: $page) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + + entries { + id + text + insertedAt + user { + id + email + fullname + pictureUrl + } + } + } + } + } + } +} diff --git a/webapp/app/queries/translation.graphql b/webapp/app/queries/translation.graphql new file mode 100644 index 00000000..a70d90da --- /dev/null +++ b/webapp/app/queries/translation.graphql @@ -0,0 +1,36 @@ +query Translation($projectId: ID!, $translationId: ID!) { + viewer { + project(id: $projectId) { + id + translation(id: $translationId) { + id + key + isConflicted + isRemoved + valueType + commentsCount + correctedText + conflictedText + updatedAt + + sourceTranslation { + id + } + + version { + id + tag + } + + revision { + id + + language { + id + name + } + } + } + } + } +} diff --git a/webapp/app/queries/translations.graphql b/webapp/app/queries/translations.graphql new file mode 100644 index 00000000..521b85f7 --- /dev/null +++ b/webapp/app/queries/translations.graphql @@ -0,0 +1,44 @@ +query Translations($projectId: ID! $revisionId: ID!, $query: String, $page: Int, $document: ID, $version: ID) { + viewer { + project(id: $projectId) { + id + + documents { + entries { + id + path + format + } + } + + versions { + entries { + id + tag + } + } + + revision(id: $revisionId) { + id + translations(query: $query, page: $page, document: $document, version: $version) { + meta { + totalEntries + totalPages + currentPage + nextPage + previousPage + } + entries { + id + key + isConflicted + correctedText + updatedAt + commentsCount + valueType + } + } + } + } + } +} diff --git a/webapp/app/queries/uncorrect-all-revision.graphql b/webapp/app/queries/uncorrect-all-revision.graphql new file mode 100644 index 00000000..33f46df3 --- /dev/null +++ b/webapp/app/queries/uncorrect-all-revision.graphql @@ -0,0 +1,11 @@ +mutation UncorrectAll($revisionId: ID!) { + uncorrectAllRevision(id: $revisionId) { + revision { + id + conflictsCount + reviewedCount + } + + errors + } +} diff --git a/webapp/app/queries/uncorrect-translation.graphql b/webapp/app/queries/uncorrect-translation.graphql new file mode 100644 index 00000000..64651fc3 --- /dev/null +++ b/webapp/app/queries/uncorrect-translation.graphql @@ -0,0 +1,13 @@ +mutation TranslationUncorrect($translationId: ID!) { + uncorrectTranslation(id: $translationId) { + translation { + id + correctedText + conflictedText + isConflicted + updatedAt + } + + errors + } +} diff --git a/webapp/app/queries/update-collaborator.graphql b/webapp/app/queries/update-collaborator.graphql new file mode 100644 index 00000000..713b4cf7 --- /dev/null +++ b/webapp/app/queries/update-collaborator.graphql @@ -0,0 +1,10 @@ +mutation CollaboratorUpdate($collaboratorId: ID!, $role: Role!) { + updateCollaborator(id: $collaboratorId, role: $role) { + collaborator { + id + role + } + + errors + } +} diff --git a/webapp/app/queries/update-integration.graphql b/webapp/app/queries/update-integration.graphql new file mode 100644 index 00000000..f592169e --- /dev/null +++ b/webapp/app/queries/update-integration.graphql @@ -0,0 +1,9 @@ +mutation IntegrationUpdate($events: [IntegrationEvent!]!, $service: IntegrationService, $integrationId: ID!, $data: IntegrationDataInput!) { + updateIntegration(id: $integrationId, events: $events, service: $service, data: $data) { + integration { + id + } + + errors + } +} diff --git a/webapp/app/queries/update-project.graphql b/webapp/app/queries/update-project.graphql new file mode 100644 index 00000000..32c9d150 --- /dev/null +++ b/webapp/app/queries/update-project.graphql @@ -0,0 +1,11 @@ +mutation ProjectUpdate($projectId: ID!, $name: String!, $isFileOperationsLocked: Boolean) { + updateProject(id: $projectId, name: $name, isFileOperationsLocked: $isFileOperationsLocked) { + project { + id + name + isFileOperationsLocked + } + + errors + } +} diff --git a/webapp/app/queries/update-translation.graphql b/webapp/app/queries/update-translation.graphql new file mode 100644 index 00000000..f70ac3f4 --- /dev/null +++ b/webapp/app/queries/update-translation.graphql @@ -0,0 +1,13 @@ +mutation TranslationUpdate($translationId: ID!, $text: String!) { + updateTranslation(id: $translationId, text: $text) { + translation { + id + correctedText + conflictedText + valueType + updatedAt + } + + errors + } +} diff --git a/webapp/app/queries/update-version.graphql b/webapp/app/queries/update-version.graphql new file mode 100644 index 00000000..1faaffdd --- /dev/null +++ b/webapp/app/queries/update-version.graphql @@ -0,0 +1,11 @@ +mutation VersionUpdate($id: ID!, $name: String!, $tag: String!) { + updateVersion(id: $id, name: $name, tag: $tag) { + version { + id + name + tag + } + + errors + } +} diff --git a/webapp/app/resolver.js b/webapp/app/resolver.js new file mode 100644 index 00000000..2fb563d6 --- /dev/null +++ b/webapp/app/resolver.js @@ -0,0 +1,3 @@ +import Resolver from 'ember-resolver'; + +export default Resolver; diff --git a/webapp/app/router.js b/webapp/app/router.js new file mode 100644 index 00000000..9ff98bf6 --- /dev/null +++ b/webapp/app/router.js @@ -0,0 +1,57 @@ +import EmberRouter from '@ember/routing/router'; +import config from './config/environment'; + +const Router = EmberRouter.extend({ + location: config.locationType, + rootURL: config.rootURL +}); + +/* eslint-disable prefer-arrow-callback */ +export default Router.map(function() { + this.route('login', {path: ''}); + + this.route('logged-in', {path: 'app'}, function() { + this.route('projects', function() { + this.route('new'); + }); + + this.route('project', {path: 'projects/:projectId'}, function() { + this.route('edit', function() { + this.route('badges'); + this.route('api-token'); + this.route('collaborators'); + this.route('service-integrations'); + this.route('manage-languages'); + }); + + this.route('activity', {path: 'activities/:activityId'}); + this.route('activities'); + this.route('files', function() { + this.route('new-sync'); + this.route('sync', {path: ':fileId/sync'}); + this.route('add-translations', {path: ':fileId/add-translations'}); + this.route('export', {path: ':fileId/export'}); + }); + this.route('versions', function() { + this.route('new'); + this.route('edit', {path: ':versionId/edit'}); + this.route('export', {path: ':versionId/export'}); + }); + this.route('comments', {path: 'conversation'}); + + this.route('revision', {path: 'revisions/:revisionId'}, function() { + this.route('translations'); + this.route('conflicts'); + }); + + this.route('translation', {path: 'translations/:translationId'}, function() { + this.route('activities'); + this.route('related-translations'); + this.route('comments', {path: 'conversation'}); + }); + }); + }); + + this.route('not-found', {path: '/*path'}); +}); +/* eslint-enable prefer-arrow-callback */ diff --git a/webapp/app/services/apollo-mutate.js b/webapp/app/services/apollo-mutate.js new file mode 100644 index 00000000..5affd541 --- /dev/null +++ b/webapp/app/services/apollo-mutate.js @@ -0,0 +1,18 @@ +import Service, {inject as service} from '@ember/service'; +import RSVP from 'rsvp'; + +export default Service.extend({ + apollo: service(), + + mutate(args) { + return this.apollo.client.mutate(args).then(this._resolve); + }, + + _resolve({data}) { + return new RSVP.Promise((resolve, reject) => { + const operationName = Object.keys(data)[0]; + + data[operationName].errors ? reject(data[operationName].errors) : resolve(data[operationName]); + }); + } +}); diff --git a/webapp/app/services/apollo.js b/webapp/app/services/apollo.js new file mode 100644 index 00000000..2b0af842 --- /dev/null +++ b/webapp/app/services/apollo.js @@ -0,0 +1,30 @@ +import Service, {inject as service} from '@ember/service'; +import apollo from 'npm:apollo-boost'; +import config from 'accent-webapp/config/environment'; + +const ApolloClient = apollo.default; +const uri = `${config.API.HOST}/graphql`; + +// Simple one-to-one interface to the apollo client query function. +export default Service.extend({ + router: service('router'), + session: service('session'), + + init() { + const client = new ApolloClient({ + uri, + request: operation => { + const token = this.session.credentials.token; + if (!token) return this.router.transitionTo('login'); + + operation.setContext({ + headers: { + authorization: `Bearer ${token}` + } + }); + } + }); + + this.set('client', client); + } +}); diff --git a/webapp/app/services/authenticated-request.js b/webapp/app/services/authenticated-request.js new file mode 100644 index 00000000..cd84aefe --- /dev/null +++ b/webapp/app/services/authenticated-request.js @@ -0,0 +1,63 @@ +import Service, {inject as service} from '@ember/service'; +import RSVP from 'rsvp'; +import fetch from 'fetch'; + +const HTTP_ERROR_STATUS = 400; + +export default Service.extend({ + session: service('session'), + + postFile(url, options) { + options.method = 'POST'; + options.headers = { + Authorization: `Bearer ${this.session.credentials.token}` + }; + options.body = this._setupFormFile(options); + delete options.file; + + return new RSVP.Promise((resolve, reject) => { + return fetch(url, options).then(response => { + if (response.status >= HTTP_ERROR_STATUS) return reject(response); + + return resolve(response); + }); + }); + }, + + commit(url, options) { + return new RSVP.Promise((resolve, reject) => { + return this.postFile(url, options) + .then(resolve) + .catch(reject); + }); + }, + + peek(url, options) { + return new RSVP.Promise((resolve, reject) => { + return this.postFile(url, options) + .then(data => data.json().then(resolve)) + .catch(reject); + }); + }, + + export(url, options) { + options.headers = { + Authorization: `Bearer ${this.session.credentials.token}` + }; + + return new RSVP.Promise((resolve, reject) => { + return fetch(url, options) + .then(data => data.text().then(resolve)) + .catch(reject); + }); + }, + + _setupFormFile({file, documentPath, documentFormat}) { + const formData = new FormData(); + formData.append('file', file); + formData.append('document_path', documentPath); + formData.append('document_format', documentFormat); + + return formData; + } +}); diff --git a/webapp/app/services/exporter.js b/webapp/app/services/exporter.js new file mode 100644 index 00000000..9a5988cd --- /dev/null +++ b/webapp/app/services/exporter.js @@ -0,0 +1,36 @@ +import Service, {inject as service} from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + authenticatedRequest: service('authenticated-request'), + + export({project, document, revision, version, documentFormat, orderBy}) { + const url = config.API.EXPORT_DOCUMENT; + documentFormat = (documentFormat || document.format).toLowerCase(); + + /* eslint-disable camelcase */ + return this.authenticatedRequest.export( + `${url}?${this.queryParams({ + inline_render: true, + language: revision.language.slug, + project_id: project.id, + version, + order_by: orderBy, + document_path: document.path, + document_format: documentFormat + })}`, + {} + ); + /* eslint-enable camelcase */ + }, + + queryParams(params) { + return Object.keys(params) + .map(k => { + if (!params[k]) return; + return `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`; + }) + .filter(nonNull => nonNull) + .join('&'); + } +}); diff --git a/webapp/app/services/file-saver.js b/webapp/app/services/file-saver.js new file mode 100644 index 00000000..34a1f2d1 --- /dev/null +++ b/webapp/app/services/file-saver.js @@ -0,0 +1,160 @@ +import Service from '@ember/service'; + +// the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to +const arbitraryRevokeTimeout = 1000 * 40; // eslint-disable-line no-magic-numbers +const bomCharCode = 0xfeff; + +const fileSaver = view => { + const doc = view.document; + + // only get URL when necessary in case Blob.js hasn't overridden it yet + const getURL = () => view.URL || view.webkitURL || view; + const saveLink = doc.createElementNS('http://www.w3.org/1999/xhtml', 'a'); + const canUseSaveLink = 'download' in saveLink; + const click = node => node.dispatchEvent(new MouseEvent('click')); + const isSafari = /constructor/i.test(view.HTMLElement) || view.safari; + const isChromeIos = /CriOS\/[\d]+/.test(navigator.userAgent); + const throwOutside = ex => { + (view.setImmediate || view.setTimeout)(() => { + throw ex; + }, 0); + }; + const forceSaveableType = 'application/octet-stream'; + + const revoke = file => { + const revoker = () => { + if (typeof file === 'string') { + // file is an object URL + getURL().revokeObjectURL(file); + } else { + // file is a File + file.remove(); + } + }; + + setTimeout(revoker, arbitraryRevokeTimeout); + }; + + const dispatch = (filesaver, eventTypes, event) => { + eventTypes = [].concat(eventTypes); + let i = eventTypes.length; + while (i--) { + const listener = filesaver[`on${eventTypes[i]}`]; + if (typeof listener === 'function') { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throwOutside(ex); + } + } + } + }; + + const autoBom = blob => { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob([String.fromCharCode(bomCharCode), blob], { + type: blob.type + }); + } + return blob; + }; + + const FileSaver = function(blob, name, noAutoBom) { + if (!noAutoBom) blob = autoBom(blob); + + // First try a.download, then web filesystem, then object URLs + const filesaver = this; + const type = blob.type; + const force = type === forceSaveableType; + let objectURL; + const dispatchAll = () => dispatch(filesaver, 'writestart progress write writeend'.split(' ')); + + // on any filesys errors revert to saving with object URLs + const fsError = () => { + if ((isChromeIos || (force && isSafari)) && view.FileReader) { + // Safari doesn't allow downloading of blob urls + const reader = new FileReader(); + + reader.onloadend = () => { + let url = isChromeIos ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); + const popup = view.open(url, '_blank'); + if (!popup) view.location.href = url; + url = undefined; // version reference before dispatching + filesaver.readyState = filesaver.DONE; + dispatchAll(); + }; + + reader.readAsDataURL(blob); + filesaver.readyState = filesaver.INIT; + return; + } + + // Don't create more object URLs than needed + if (!objectURL) objectURL = getURL().createObjectURL(blob); + + if (force) { + view.location.href = objectURL; + } else { + const opened = view.open(objectURL, '_blank'); + if (!opened) { + // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html + view.location.href = objectURL; + } + } + + filesaver.readyState = filesaver.DONE; + dispatchAll(); + revoke(objectURL); + }; + + filesaver.readyState = filesaver.INIT; + + if (canUseSaveLink) { + objectURL = getURL().createObjectURL(blob); + setTimeout(() => { + saveLink.href = objectURL; + saveLink.download = name; + click(saveLink); + dispatchAll(); + revoke(objectURL); + filesaver.readyState = filesaver.DONE; + }); + return; + } + + fsError(); + }; + + const FSproto = FileSaver.prototype; + + // IE 10+ (native saveAs) + if (typeof navigator !== 'undefined' && navigator.msSaveOrOpenBlob) { + return (blob, name, noAutoBom) => { + name = name || blob.name || 'download'; + + if (!noAutoBom) blob = autoBom(blob); + return navigator.msSaveOrOpenBlob(blob, name); + }; + } + + FSproto.abort = () => {}; + FSproto.readyState = FSproto.INIT = 0; + FSproto.WRITING = 1; + FSproto.DONE = 2; + + FSproto.error = FSproto.onwritestart = FSproto.onprogress = FSproto.onwrite = FSproto.onabort = FSproto.onerror = FSproto.onwriteend = null; + + return (blob, name, noAutoBom) => new FileSaver(blob, name || blob.name || 'download', noAutoBom); +}; + +export default Service.extend({ + init() { + this.set('fileSaver', fileSaver(window)); + }, + + saveAs(...options) { + return this.fileSaver(...options); + } +}); diff --git a/webapp/app/services/global-state.js b/webapp/app/services/global-state.js new file mode 100644 index 00000000..8f9373eb --- /dev/null +++ b/webapp/app/services/global-state.js @@ -0,0 +1,12 @@ +import {computed} from '@ember/object'; +import Service from '@ember/service'; + +export default Service.extend({ + selectedRevision: null, + + permissions: computed(() => ({})), + roles: computed(() => []), + documentFormats: computed(() => []), + + isProjectNavigationListShowing: false +}); diff --git a/webapp/app/services/language-searcher.js b/webapp/app/services/language-searcher.js new file mode 100644 index 00000000..16fc259a --- /dev/null +++ b/webapp/app/services/language-searcher.js @@ -0,0 +1,25 @@ +import Service, {inject as service} from '@ember/service'; +import RSVP from 'rsvp'; + +import searchLanguagesQuery from 'accent-webapp/queries/languages-search'; + +const MINIMUM_TERM_LENGTH = 3; + +export default Service.extend({ + apollo: service('apollo'), + + search({term}) { + const searchQuery = { + query: searchLanguagesQuery, + variables: {query: term} + }; + + return new RSVP.Promise(resolve => { + if (term.length < MINIMUM_TERM_LENGTH) return resolve([]); + + return this.apollo.client.query(searchQuery).then(({data: {languages: {entries}}}) => { + return resolve(entries); + }); + }); + } +}); diff --git a/webapp/app/services/merger.js b/webapp/app/services/merger.js new file mode 100644 index 00000000..5581db98 --- /dev/null +++ b/webapp/app/services/merger.js @@ -0,0 +1,19 @@ +import fmt from 'npm:simple-fmt'; +import Service, {inject as service} from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + authenticatedRequest: service('authenticated-request'), + + merge({project, revision, file, documentPath, documentFormat, mergeType}) { + const language = revision.language; + const url = fmt(config.API.MERGE_REVISION_PATH, project.id, language.slug, mergeType); + documentFormat = documentFormat.toLowerCase(); + + return this.authenticatedRequest.commit(url, { + file, + documentPath, + documentFormat + }); + } +}); diff --git a/webapp/app/services/peeker.js b/webapp/app/services/peeker.js new file mode 100644 index 00000000..027801ee --- /dev/null +++ b/webapp/app/services/peeker.js @@ -0,0 +1,57 @@ +import fmt from 'npm:simple-fmt'; +import EmberObject from '@ember/object'; +import Service, {inject as service} from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + authenticatedRequest: service('authenticated-request'), + + sync({project, revision, revisions, file, documentPath, documentFormat}) { + const url = fmt(config.API.SYNC_PEEK_PROJECT_PATH, project.id, revision.language.slug); + documentFormat = documentFormat.toLowerCase(); + + return this.authenticatedRequest.peek(url, {file, documentPath, documentFormat}).then(({data: {operations, stats}}) => { + return revisions.map(revision => this._mapOperations(revision, operations, stats)); + }); + }, + + merge({revision, project, file, mergeType, documentPath, documentFormat}) { + const url = fmt(config.API.MERGE_PEEK_PROJECT_PATH, project.id, revision.language.slug, mergeType); + documentFormat = documentFormat.toLowerCase(); + + return this.authenticatedRequest + .peek(url, {file, documentPath, documentFormat}) + .then(({data: {operations, stats}}) => [this._mapOperations(revision, operations, stats)]); + }, + + _mapOperations(revision, operations, stats) { + return EmberObject.create({ + language: revision.language, + stats: this._mapOperationStats(stats[revision.id]), + operations: this._mapOperationItems(operations[revision.id]) + }); + }, + + _mapOperationStats(stats) { + if (!stats) return []; + + return Object.keys(stats).map(action => { + const count = stats[action]; + + return EmberObject.create({action, count}); + }); + }, + + _mapOperationItems(operations) { + if (!operations) return []; + + return operations.map(operation => { + return EmberObject.create({ + action: operation.action, + key: operation.key, + text: operation.text, + previousText: operation['previous-text'] + }); + }); + } +}); diff --git a/webapp/app/services/raven.js b/webapp/app/services/raven.js new file mode 100644 index 00000000..54a251d9 --- /dev/null +++ b/webapp/app/services/raven.js @@ -0,0 +1,201 @@ +import RSVP from 'rsvp'; +import Service from '@ember/service'; +import {computed} from '@ember/object'; +import {typeOf} from '@ember/utils'; +import Ember from 'ember'; +import Raven from 'npm:raven-js'; + +/** + * Default available logger service. + * + * You can simply extend or export this Service to use it in the application. + * + * @class RavenService + * @module ember-cli-sentry/services/raven + * @extends Ember.Service + */ +export default Service.extend({ + /** + * Global error catching definition status + * + * @property globalErrorCatchingInitialized + * @type Boolean + * @default false + * @private + */ + globalErrorCatchingInitialized: false, + + /** + * Message to send to Raven when facing an unhandled + * RSVP.Promise rejection. + * + * @property unhandledPromiseErrorMessage + * @type String + * @default 'Unhandled Promise error detected' + */ + unhandledPromiseErrorMessage: 'Unhandled Promise error detected', + + /** + * Utility function used internally to check if Raven object + * can capture exceptions and messages properly. + * + * @property isRavenUsable + * @type Ember.ComputedProperty + */ + isRavenUsable: computed(() => { + return !!(Raven && Raven.isSetup() === true); + }).volatile(), + + /** + * Tries to have Raven capture exception, or throw it. + * + * @method captureException + * @param {Error} error The error to capture + * @throws {Error} An error if Raven cannot capture the exception + */ + captureException(error) { + if (this.isRavenUsable) { + Raven.captureException(...arguments); + } else { + throw error; + } + }, + + /** + * Tries to have Raven capture message, or send it to console. + * + * @method captureMessage + * @param {String} message The message to capture + * @return {Boolean} + */ + captureMessage(message) { + if (this.isRavenUsable) { + Raven.captureMessage(...arguments); + } else { + throw new Error(message); + } + return true; + }, + + /** + * Binds functions to `Ember.onerror` and `Ember.RSVP.on('error')`. + * + * @method enableGlobalErrorCatching + * @chainable + * @see http://emberjs.com/api/#event_onerror + */ + enableGlobalErrorCatching() { + if (this.isRavenUsable && !this.globalErrorCatchingInitialized) { + const _oldOnError = Ember.onerror; + + Ember.onerror = error => { + if (this._ignoreError(error)) { + return; + } + + this.captureException(error); + this.didCaptureException(error); + if (typeof _oldOnError === 'function') { + _oldOnError.call(Ember, error); + } + }; + + RSVP.on('error', (reason, label) => { + if (this._ignoreError(reason)) { + return; + } + + if (typeOf(reason) === 'error') { + this.captureException(reason, { + extra: { + context: label || this.unhandledPromiseErrorMessage + } + }); + this.didCaptureException(reason); + } else { + this.captureMessage(this._extractMessage(reason), { + extra: { + reason, + context: label + } + }); + } + }); + + this.set('globalErrorCatchingInitialized', true); + } + + return this; + }, + + /** + * This private method tries to find a reasonable message when + * an unhandled promise does not reject to an error. + * + * @method _extractMessage + * @param {any} reason + * @return {String} + */ + _extractMessage(reason) { + const defaultMessage = this.unhandledPromiseErrorMessage; + switch (typeOf(reason)) { + case 'string': + return reason; + case 'object': + return reason.message || defaultMessage; + default: + return defaultMessage; + } + }, + + _ignoreError(error) { + // Ember 2.X seems to not catch `TransitionAborted` errors caused by regular redirects. We don't want these errors to show up in Sentry so we have to filter them ourselfs. + // Once the issue https://github.com/emberjs/ember.js/issues/12505 is resolved we can remove this ignored error. + if (error && error.name === 'TransitionAborted') { + return true; + } + + return this.ignoreError(error); + }, + + /** + * Hook that allows for custom behavior when an + * error is captured. An example would be to open + * a feedback modal. + * + * @method didCaptureException + * @param {Error} error + */ + didCaptureException() {}, + + /** + * Hook that allows error filtering in global + * error catching methods. + * + * @method ignoreError + * @param {Error} error + * @return {Boolean} + */ + ignoreError() { + return false; + }, + + /** + * Runs a Raven method if it is available. + * + * @param {String} methodName The method to call + * @param {Array} ...optional Optional method arguments + * @return {any} Raven method return value or false + * @throws {Error} If an error is captured and thrown + */ + callRaven(methodName, ...optional) { + if (this.isRavenUsable) { + try { + return Raven[methodName](Raven, ...optional); + } catch (error) { + this.captureException(error); + return false; + } + } + } +}); diff --git a/webapp/app/services/session.js b/webapp/app/services/session.js new file mode 100644 index 00000000..c750fc6a --- /dev/null +++ b/webapp/app/services/session.js @@ -0,0 +1,34 @@ +import {computed} from '@ember/object'; +import {readOnly} from '@ember/object/computed'; +import Service, {inject as service} from '@ember/service'; + +export default Service.extend({ + sessionFetcher: service('session/fetcher'), + sessionPersister: service('session/persister'), + sessionCreator: service('session/creator'), + sessionDestroyer: service('session/destroyer'), + + googleAuth: null, + + isAuthenticated: readOnly('credentials.user'), + + credentials: computed({ + get() { + return this.sessionFetcher.fetch(); + }, + + set(_, value) { + this.sessionPersister.persist(value); + return value; + } + }), + + login(...args) { + return this.sessionCreator.createSession(...args).then(credentials => this.set('credentials', credentials)); + }, + + logout() { + this.sessionDestroyer.destroySession(); + if (this.googleAuth) this.googleAuth.disconnect(); + } +}); diff --git a/webapp/app/services/session/creator.js b/webapp/app/services/session/creator.js new file mode 100644 index 00000000..f46a27fc --- /dev/null +++ b/webapp/app/services/session/creator.js @@ -0,0 +1,23 @@ +import Service from '@ember/service'; +import RSVP from 'rsvp'; +import config from 'accent-webapp/config/environment'; +import fetch from 'fetch'; + +export default Service.extend({ + createSession({token, provider}) { + return new RSVP.Promise((resolve, reject) => { + const uid = token; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({uid, provider}) + }; + + fetch(config.API.AUTHENTICATION_PATH, options) + .then(data => data.json().then(resolve)) + .catch((_jqXHR, _textStatus, error) => reject(error)); + }); + } +}); diff --git a/webapp/app/services/session/destroyer.js b/webapp/app/services/session/destroyer.js new file mode 100644 index 00000000..ff5919e7 --- /dev/null +++ b/webapp/app/services/session/destroyer.js @@ -0,0 +1,9 @@ +import Service from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + destroySession() { + const session = config.APP.LOCAL_STORAGE.SESSION_NAMESPACE; + localStorage.removeItem(session); + } +}); diff --git a/webapp/app/services/session/fetcher.js b/webapp/app/services/session/fetcher.js new file mode 100644 index 00000000..14784aac --- /dev/null +++ b/webapp/app/services/session/fetcher.js @@ -0,0 +1,11 @@ +import Service from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + fetch() { + const credentials = localStorage.getItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE); + if (!credentials) return {}; + + return JSON.parse(credentials); + } +}); diff --git a/webapp/app/services/session/persister.js b/webapp/app/services/session/persister.js new file mode 100644 index 00000000..63885edf --- /dev/null +++ b/webapp/app/services/session/persister.js @@ -0,0 +1,8 @@ +import Service from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + persist(session) { + localStorage.setItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE, JSON.stringify(session)); + } +}); diff --git a/webapp/app/services/syncer.js b/webapp/app/services/syncer.js new file mode 100644 index 00000000..8928d40c --- /dev/null +++ b/webapp/app/services/syncer.js @@ -0,0 +1,18 @@ +import fmt from 'npm:simple-fmt'; +import Service, {inject as service} from '@ember/service'; +import config from 'accent-webapp/config/environment'; + +export default Service.extend({ + authenticatedRequest: service('authenticated-request'), + + sync({revision, project, file, documentPath, documentFormat}) { + const url = fmt(config.API.SYNC_PROJECT_PATH, project.id, revision.language.slug); + documentFormat = documentFormat.toLowerCase(); + + return this.authenticatedRequest.commit(url, { + file, + documentPath, + documentFormat + }); + } +}); diff --git a/webapp/app/styles/app.scss b/webapp/app/styles/app.scss new file mode 100644 index 00000000..c394521b --- /dev/null +++ b/webapp/app/styles/app.scss @@ -0,0 +1,23 @@ +// Variables +@import 'variables/colors'; +@import 'variables/fonts'; +@import 'variables/dimensions'; +@import 'variables/transitions'; +@import 'variables/power-select'; + +@import 'modal'; + +@import 'reset'; +@import 'base'; +@import 'classes'; +@import 'reset-select'; + +@import 'html-components/sub-navigation'; +@import 'html-components/filters'; +@import 'html-components/wrapper'; +@import 'html-components/power-select'; + +@import 'html-components/form/button'; +@import 'html-components/form/label'; + +@import 'pod-styles'; diff --git a/webapp/app/styles/base.scss b/webapp/app/styles/base.scss new file mode 100644 index 00000000..98376db5 --- /dev/null +++ b/webapp/app/styles/base.scss @@ -0,0 +1,31 @@ +::-moz-selection { background: lighten($color-primary, 45%); } +::selection { background: lighten($color-primary, 45%); } + +body { + font-family: $font-primary; + line-height: 1.5; +} + +textarea, +select, +button, +input[type="submit"], +input[type="text"] { + font-family: $font-primary; +} + +dl, +dt, +dd { + margin: 0; +} + +.app { + display: flex; + min-height: 100vh; + flex-direction: column; +} + +.app-content { + flex: 1; +} diff --git a/webapp/app/styles/classes.scss b/webapp/app/styles/classes.scss new file mode 100644 index 00000000..13111996 --- /dev/null +++ b/webapp/app/styles/classes.scss @@ -0,0 +1,38 @@ +%centeredWrapper { + margin: 0 auto; + max-width: $screen-lg; +} + +%translationKeyBase { + color: $color-black; + font-family: $font-monospace; + word-break: break-all; +} + +%translationTextBase { + white-space: pre-wrap; + color: $color-grey; +} + +%textInput { + transition: $transition-speed $transition-easing; + transition-property: border box-shadow; + resize: vertical; + outline: 0; + border-radius: 4px; + border: 2px solid lighten($color-grey, 20%); + background: $color-white; + font-family: $font-monospace; + line-height: 1.4; + + &:focus { + border: 2px solid rgba($color-primary, 0.7); + } +} + +@media (max-width: ($screen-lg + 40px)) { + %centeredWrapper { + padding-left: 20px; + padding-right: 20px; + } +} diff --git a/webapp/app/styles/html-components/filters.scss b/webapp/app/styles/html-components/filters.scss new file mode 100644 index 00000000..ad22b730 --- /dev/null +++ b/webapp/app/styles/html-components/filters.scss @@ -0,0 +1,49 @@ +.filters { + padding: 15px; + border-radius: 3px; + background: $color-light-background; + + &.filters--white { + background: $color-white; + } + + .ember-power-select-trigger { + background: darken($color-light-background, 2%); + box-shadow: none; + border: 1px solid rgba($color-black, 0.07); + color: rgba($color-black, 0.8); + + &:focus, + &.ember-power-select-trigger--active { + background: darken($color-light-background, 7%); + border: 1px solid rgba($color-black, 0.1); + } + } +} + +.filters-wrapper { + position: relative; +} + +.queryForm-filters { + display: flex; +} + +.queryForm-filter { + display: flex; + align-items: center; + margin-top: 10px; + margin-right: 15px; +} + +.queryForm-filter-label { + margin-right: 10px; + color: $color-grey; + font-size: 12px; + font-style: italic; +} + +.queryForm-filter-select { + font-size: 13px; + min-width: 130px; +} diff --git a/webapp/app/styles/html-components/form/button.scss b/webapp/app/styles/html-components/form/button.scss new file mode 100644 index 00000000..0c33b72e --- /dev/null +++ b/webapp/app/styles/html-components/form/button.scss @@ -0,0 +1,291 @@ +.button { + display: inline-flex; + align-items: center; + transition: $transition-speed $transition-easing; + transition-property: all; + appearance: none; + margin: 0; + border: 0; + text-decoration: none; + border-radius: $border-radius; + outline: none; + cursor: pointer; + background: none; + font-weight: bold; + font-size: 12px; + + &[disabled] { + opacity: 0.5; + cursor: default; + } +} + +.button-icon { + position: relative; + top: 3px; + transition: $transition-speed $transition-easing; + transition-property: fill; + width: 14px; + height: 14px; + margin: -3px 4px 3px -2px; +} + +.button--bordered { + padding: 5px 12px; + border: 1px solid; + border-radius: $border-radius; + font-size: 12px; + + .button-icon { + fill: darken(#ccc, 4%); + } +} + +.button--iconOnly { + .button-icon { + top: 1px; + margin: 0; + } +} + +.button--filled { + padding: 5px 12px; + background: $color-secondary; + box-shadow: 0 1px 2px rgba($color-black, 0.1); + border: 1px solid darken($color-secondary, 4%); + text-shadow: 0 1px 1px rgba($color-black, 0.2); + color: $color-white; + + .button-icon { + fill: $color-white; + } + + &:active { + background: darken($color-secondary, 4%); + } + + &:focus { + box-shadow: 0 0 10px lighten($color-secondary, 20%); + } + + &:hover, + &:focus { + background: darken($color-secondary, 2%); + } + + &.button--red { + background: $color-error; + border: 1px solid darken($color-error, 4%); + color: $color-white; + + .button-icon { + fill: $color-white; + } + + &:focus { + box-shadow: 0 0 10px lighten($color-error, 20%); + } + + &:active { + background: darken($color-error, 6%); + } + + &:hover, + &:focus { + background: darken($color-error, 4%); + color: $color-white; + } + } + + &.button--grey { + background: #ccc; + border: 1px solid darken(#ccc, 4%); + color: $color-white; + + &:focus { + box-shadow: 0 0 10px darken(#ccc, 7%); + } + + &:hover, + &:focus { + background: darken(#ccc, 4%); + color: $color-white; + } + } + + &.button--black { + background: lighten($color-true-black, 6%); + border: 1px solid darken($color-true-black, 4%); + color: $color-white; + + &:focus { + box-shadow: 0 0 10px darken($color-true-black, 10%); + } + + &:hover, + &:focus { + background: lighten($color-black, 2%); + color: $color-white; + } + } + + &.button--blue { + background: $color-blue; + border: 1px solid darken($color-blue, 4%); + color: $color-white; + + &:focus { + box-shadow: 0 0 10px lighten($color-blue, 27%); + } + + &:hover, + &:focus { + background: darken($color-blue, 4%); + color: $color-white; + } + } + + &.button--white { + background: $color-white; + border: 1px solid darken(#ccc, 4%); + color: darken($color-grey, 20%); + text-shadow: none; + + &:focus { + box-shadow: 0 0 10px lighten($color-grey, 27%); + } + + &:hover, + &:focus { + .button-icon { + fill: $color-black; + } + + background: darken($color-white, 4%); + color: $color-black; + } + + &[disabled] { + &:hover, + &:focus { + .button-icon { + fill: $color-grey; + } + + background: $color-white; + color: darken($color-grey, 20%); + } + } + + .loading { + fill: darken($color-grey, 50%); + } + + .button-icon { + fill: darken($color-grey, 10%); + } + } +} + +.button--big { + padding: 7px 30px 8px; + font-weight: bold; + font-size: 13px; +} + +.button--disabled, +.button--dimmed { + opacity: 0.5; +} + +.button--disabled { + cursor: default; +} + +.button--filled.button--borderLess { + border-color: transparent; + box-shadow: none; +} + +.button--cancel { + align-self: center; + flex-shrink: 0; + flex-grow: 0; + position: relative; + top: -1px; + padding: 1px 3px; + margin: 0 4px; + border: 0; + border-radius: 50%; + background: #dbdbdb; + font-size: 9px; + color: $color-white; + + &:hover, + &:focus { + background: darken(#dbdbdb, 5%); + } +} + +.button--grey { + color: #8a8a8a; + border-color: #dbdbdb; + + .button-icon { + fill: #8a8a8a; + } + + &:hover, + &:focus { + color: darken(#8a8a8a, 10%); + background: lighten(#8a8a8a, 43%); + border-color: darken(#dbdbdb, 10%); + } +} + +.button--green { + color: $color-success; + + .button-icon { + fill: $color-success; + } + + &:hover, + &:focus { + background: lighten($color-success, 43%); + color: $color-dark-success; + } +} + +.button--red { + color: $color-error; + + .button-icon { + fill: $color-error; + } + + &:hover, + &:focus { + background: lighten($color-error, 41%); + color: $color-dark-error; + } +} + +.button--primary { + color: $color-primary; + + .button-icon { + fill: $color-primary; + } + + &:hover, + &:focus { + color: darken($color-primary, 15%); + } +} + +.button--text { + background: transparent; + font-size: 12px; + font-weight: 400; +} diff --git a/webapp/app/styles/html-components/form/label.scss b/webapp/app/styles/html-components/form/label.scss new file mode 100644 index 00000000..e87c5e7f --- /dev/null +++ b/webapp/app/styles/html-components/form/label.scss @@ -0,0 +1,8 @@ +.label { + display: inline-flex; + align-items: center; +} + +.label-text { + margin-left: 10px; +} diff --git a/webapp/app/styles/html-components/power-select.scss b/webapp/app/styles/html-components/power-select.scss new file mode 100644 index 00000000..05049a8d --- /dev/null +++ b/webapp/app/styles/html-components/power-select.scss @@ -0,0 +1,179 @@ +.ember-basic-dropdown { + position: relative; +} + +.ember-basic-dropdown-content { + position: absolute; + width: auto; + z-index: 4000; + background-color: #fff; +} + +.ember-basic-dropdown-content--left { left: 0; } +.ember-basic-dropdown-content--right { right: 0; } + +.ember-basic-dropdown-content-wormhole-origin { + display: inline; +} + +.ember-power-select { + position: relative; +} + +// Trigger +.ember-power-select-trigger { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px; + border-top: $ember-power-select-trigger-border-top; + border-bottom: $ember-power-select-trigger-border-bottom; + border-right: $ember-power-select-trigger-border-right; + border-left: $ember-power-select-trigger-border-left; + border-color: transparent; + border-radius: $ember-power-select-trigger-default-border-radius; + background-color: transparent; + overflow-x: hidden; + text-overflow: ellipsis; + user-select: none; + font-size: 13px; + color: $ember-power-select-text-color; + cursor: pointer; + + &:focus, + &.ember-power-select-trigger--active { + border-top: $ember-power-select-active-trigger-border-top; + border-bottom: $ember-power-select-active-trigger-border-bottom; + border-right: $ember-power-select-active-trigger-border-right; + border-left: $ember-power-select-active-trigger-border-left; + outline: none; + } +} + +.ember-basic-dropdown-trigger--below.ember-power-select-trigger[aria-expanded="true"], +.ember-basic-dropdown-trigger--in-place.ember-power-select-trigger[aria-expanded="true"] { + border-bottom-left-radius: $ember-power-select-opened-border-radius; + border-bottom-right-radius: $ember-power-select-opened-border-radius; +} + +.ember-basic-dropdown-trigger--above.ember-power-select-trigger[aria-expanded="true"] { + border-top-left-radius: $ember-power-select-opened-border-radius; + border-top-right-radius: $ember-power-select-opened-border-radius; +} + +.ember-power-select-selected-item { + flex: 1 1 auto; + margin-right: 20px; + + em { + margin-left: 4px; + font-weight: bold; + font-size: 11px; + font-style: normal; + color: $color-primary; + } +} + +.ember-power-select-status-icon { + position: relative; + display: block; + font-size: 18px; + + &:after { + content: '›'; + position: absolute; + bottom: -14px; + left: -12px; + transform: rotate(90deg); + } + + .ember-basic-dropdown-trigger[aria-expanded="true"] & { + &:after { + left: -16px; + bottom: -13px; + transform: rotate(-90deg); + } + } +} + +// Dropdown +.ember-power-select-dropdown { + border-left: $ember-power-select-dropdown-left-border; + border-right: $ember-power-select-dropdown-right-border; + border-radius: $ember-power-select-dropdown-default-border-radius; + box-shadow: 0 2px 5px rgba($color-black, 0.2); + border-color: #ddd; + overflow: hidden; + color: $ember-power-select-text-color; + font-size: 14px; +} + +.ember-power-select-dropdown.ember-basic-dropdown-content--above { + border-top: $ember-power-select-dropdown-top-border; + border-bottom: $ember-power-select-dropdown-contiguous-border; + border-bottom-left-radius: $ember-power-select-opened-border-radius; + border-bottom-right-radius: $ember-power-select-opened-border-radius; +} + +.ember-power-select-dropdown.ember-basic-dropdown-content--below, +.ember-power-select-dropdown.ember-basic-dropdown-content--in-place { + border-top: $ember-power-select-dropdown-contiguous-border; + border-bottom: $ember-power-select-dropdown-bottom-border; + border-top-left-radius: $ember-power-select-opened-border-radius; + border-top-right-radius: $ember-power-select-opened-border-radius; +} + +.ember-power-select-dropdown.ember-basic-dropdown-content--in-place { + width: 100%; +} + +.ember-power-select-option { + cursor: pointer; + padding: 6px 10px; + color: #333; + + em { + font-size: 11px; + font-style: normal; + color: rgba($color-black, 0.7); + } +} + +.ember-power-select-group[aria-disabled="true"] { + color: $ember-power-select-disabled-option-color; + cursor: not-allowed; +} + +.ember-power-select-group[aria-disabled="true"] .ember-power-select-option, +.ember-power-select-option[aria-disabled="true"] { + color: $ember-power-select-disabled-option-color; + pointer-events: none; + cursor: not-allowed; +} + +.ember-power-select-option[aria-selected="true"] { + background-color: #fff; + color: $color-primary; +} + +.ember-power-select-option[aria-current="true"] { + background-color: $ember-power-select-highlighted-background; + color: $ember-power-select-highlighted-color; + + em { + color: rgba(#fff, 0.7); + } +} + +// Disabled styles +.ember-power-select-trigger[aria-disabled=true] { + background-color: $ember-power-select-disabled-background-color; + pointer-events: none; +} + +.ember-power-select-search-input { + width: 100%; + padding: 10px; + font-size: 14px; +} diff --git a/webapp/app/styles/html-components/sub-navigation.scss b/webapp/app/styles/html-components/sub-navigation.scss new file mode 100644 index 00000000..4501c0fc --- /dev/null +++ b/webapp/app/styles/html-components/sub-navigation.scss @@ -0,0 +1,113 @@ +.subNavigation { + border-radius: 3px; + overflow: hidden; +} + +.subNavigation--alt { + background: $color-light-background; + + .subNavigation-list-item { + margin-right: 6px; + } + + .subNavigation-list-item-link { + padding: 10px 15px 10px 10px; + + &.active { + background: darken(#f3f9f6, 3%); + color: $color-primary; + } + } +} + +.subNavigation-list { + display: flex; + align-items: stretch; + position: relative; + left: -6px; + z-index: 10; +} + +.subNavigation-list-item { + padding: 0 5px 0; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } +} + +.subNavigation-list-item-link { + transition: $transition-speed $transition-easing; + transition-property: color, border-color, background; + display: flex; + align-items: center; + height: 100%; + position: relative; + padding: 5px 5px 6px 3px; + text-decoration: none; + font-size: 14px; + font-weight: 600; + color: $color-grey; + + &:focus { + color: $color-primary; + + .subNavigation-list-item-link-icon { + fill: $color-primary; + } + } + + &:hover { + background: #f3f3f3; + } + + &.active { + .subNavigation-list-item-link-icon { + fill: $color-primary; + } + } +} + +.subNavigation-list-item-link-icon { + transition: $transition-speed $transition-easing; + transition-property: fill; + width: 20px; + height: 20px; + margin-right: 5px; + fill: rgba($color-black, 0.3); +} + +@media (max-width: ($screen-md)) { + .subNavigation-list { + justify-content: space-between; + } + + .subNavigation-list-item-link { + flex-direction: column; + font-size: 12px; + text-align: center; + } + + .subNavigation-list-item-link-icon { + margin-right: 0; + margin-bottom: 5px; + } +} + +@media (max-width: ($screen-sm)) { + .subNavigation-list-item { + width: 100%; + margin-right: 0; + } + + .subNavigation-list-item-link-text { + display: none; + } + + .subNavigation--alt { + .subNavigation-list-item-link { + padding: 8px 15px 5px 15px; + } + } +} diff --git a/webapp/app/styles/html-components/wrapper.scss b/webapp/app/styles/html-components/wrapper.scss new file mode 100644 index 00000000..6be53ec9 --- /dev/null +++ b/webapp/app/styles/html-components/wrapper.scss @@ -0,0 +1,49 @@ +.wrapper { + display: flex; + align-items: flex-start; + margin: 0 auto; + max-width: $screen-lg; +} + +.wrapper-loading { + width: 200px; + margin: 50px auto; +} + +.wrapper-sidebar { + flex: 0 0 225px; +} + +.wrapper-content { + flex: 1 1 auto; + width: 100%; + padding: 0 20px 0 30px; + margin-top: 40px; +} + +@media (max-width: 800px) { + .wrapper-content { + padding-left: 5px; + } + + .wrapper-sidebar { + flex: 0 0 55px; + } +} + +@media (max-width: ($screen-sm)) { + .wrapper { + flex-direction: column; + } + + .wrapper-content { + margin-top: 0; + padding: 0 10px; + } + + .wrapper-sidebar { + flex: 0 0 auto; + width: 100%; + border: 0; + } +} diff --git a/webapp/app/styles/modal.scss b/webapp/app/styles/modal.scss new file mode 100644 index 00000000..ea95133a --- /dev/null +++ b/webapp/app/styles/modal.scss @@ -0,0 +1,47 @@ +.acc-modal__wrapper { + display: flex; + align-items: flex-start; + justify-content: center; + position: fixed; + z-index: 3000; + height: 100vh; + left: 0; + right: 0; + top: 0; + overflow-y: scroll; + padding: 100px 30px; +} + +.acc-modal__overlay { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + min-height: 100vh; + background-color: rgba(#000, 0.5); + opacity: 1; +} + +.acc-modal__container { + display: block; + position: relative; + z-index: 3; + max-width: 900px; + width: 100%; + box-shadow: 0 4px 25px 4px rgba(#000, 0.3); +} + +.modalList { + display: none; +} + +@media (max-width: ($screen-sm)) { + .acc-modal__wrapper { + padding: 30px 10px; + } + + .modalList { + display: block; + } +} diff --git a/webapp/app/styles/reset-select.scss b/webapp/app/styles/reset-select.scss new file mode 100644 index 00000000..fe895d6c --- /dev/null +++ b/webapp/app/styles/reset-select.scss @@ -0,0 +1,30 @@ +.resetSelect { + position: relative; + display: inline-block; + vertical-align: middle; + border-radius: 3px; + + &:before, + &:after { + content: ''; + position: absolute; + pointer-events: none; + } + + &:after { + content: '›'; + transform: rotate(90deg); + top: 50%; + right: 0; + } +} + +.resetSelect-tag { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + margin: 0; + border: 0; + cursor: pointer; + outline: none; +} diff --git a/webapp/app/styles/reset.css b/webapp/app/styles/reset.css new file mode 100644 index 00000000..6d9b66ae --- /dev/null +++ b/webapp/app/styles/reset.css @@ -0,0 +1,141 @@ +/* + The code below has been extracted from the following projects: + + - https://github.com/murtaugh/HTML5-Reset + - http://meyerweb.com + - http://html5doctor.com + - http://html5boilerplate.com + + … and then cleaned up a lot. +*/ + +html, +body, +div, +span, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +abbr, +code, +em, +img, +small, +strong, +sub, +sup, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +footer, +header, +nav, +section, +time, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font-weight: inherit; + vertical-align: baseline; + background: transparent; +} + +article, +aside, +figure, +footer, +header, +nav, +section { + display: block; +} + +html { + box-sizing: border-box; + overflow-y: scroll; +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +img, +object { + max-width: 100%; +} + +ul { + list-style: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +th { + font-weight: bold; + vertical-align: bottom; +} + +td { + font-weight: normal; + vertical-align: top; +} + +input, +select { + vertical-align: middle; +} + +input[type="radio"] { + vertical-align: text-bottom; +} + +input[type="checkbox"] { + vertical-align: bottom; +} + +strong { + font-weight: bold; +} + +label, +input[type="file"], +button { + cursor: pointer; +} + +button, +input, +select, +textarea { + margin: 0; + border: 0; +} diff --git a/webapp/app/styles/variables/colors.scss b/webapp/app/styles/variables/colors.scss new file mode 100644 index 00000000..cb5b6d43 --- /dev/null +++ b/webapp/app/styles/variables/colors.scss @@ -0,0 +1,23 @@ +$color-primary: #28cb87; +$color-secondary: #1ecd8d; +$color-blue: #3f7cc5; +$color-border: #e0e0e0; + +$color-white: #fff; +$color-true-black: #1a211d; +$color-black: #1f4831; + +$color-grey: #adadad; +$color-dark-grey: #6e7173; +$color-light-grey: #fafafa; +$color-light-background: #fafafa; + +$color-success: #45c86f; +$color-dark-success: darken($color-success, 5%); + +$color-warning: #e4b600; + +$color-socket: #2c4fb4; + +$color-error: #d84444; +$color-dark-error: darken($color-error, 5%); diff --git a/webapp/app/styles/variables/dimensions.scss b/webapp/app/styles/variables/dimensions.scss new file mode 100644 index 00000000..977d1023 --- /dev/null +++ b/webapp/app/styles/variables/dimensions.scss @@ -0,0 +1,5 @@ +$screen-lg: 1200px; +$screen-md: 640px; +$screen-sm: 440px; + +$border-radius: 3px; diff --git a/webapp/app/styles/variables/fonts.scss b/webapp/app/styles/variables/fonts.scss new file mode 100644 index 00000000..e5b6ebb9 --- /dev/null +++ b/webapp/app/styles/variables/fonts.scss @@ -0,0 +1,2 @@ +$font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +$font-monospace: 'Fira Mono', 'Monaco', Courrier, monospace; diff --git a/webapp/app/styles/variables/power-select.scss b/webapp/app/styles/variables/power-select.scss new file mode 100644 index 00000000..3a124869 --- /dev/null +++ b/webapp/app/styles/variables/power-select.scss @@ -0,0 +1,50 @@ +// Backgrounds +$ember-power-select-background-color: #ffffff !default; +$ember-power-select-disabled-background-color: #eeeeee !default; +$ember-power-select-multiple-selection-background-color: #e4e4e4 !default; +$ember-power-select-highlighted-background: $color-primary !default; +$ember-power-select-selected-background: #dddddd !default; + +// Texts +$ember-power-select-text-color: inherit !default; +$ember-power-select-placeholder-color: #999999 !default; +$ember-power-select-highlighted-color: #ffffff !default; +$ember-power-select-disabled-option-color: #999999 !default; +$ember-power-select-multiple-selection-color: #333333 !default; + +// Borders +$ember-power-select-border-color: #aaaaaa !default; +$ember-power-select-focus-border-color: $ember-power-select-border-color !default; +$ember-power-select-default-border: 1px solid $ember-power-select-border-color !default; +$ember-power-select-default-focus-border: 0 !default; + +$ember-power-select-trigger-border: $ember-power-select-default-border !default; +$ember-power-select-trigger-border-top: $ember-power-select-trigger-border !default; +$ember-power-select-trigger-border-bottom: $ember-power-select-trigger-border !default; +$ember-power-select-trigger-border-right: $ember-power-select-trigger-border !default; +$ember-power-select-trigger-border-left: $ember-power-select-trigger-border !default; +$ember-power-select-active-trigger-border: $ember-power-select-default-focus-border !default; +$ember-power-select-active-trigger-border-top: $ember-power-select-active-trigger-border !default; +$ember-power-select-active-trigger-border-bottom: $ember-power-select-active-trigger-border !default; +$ember-power-select-active-trigger-border-right: $ember-power-select-active-trigger-border !default; +$ember-power-select-active-trigger-border-left: $ember-power-select-active-trigger-border !default; +$ember-power-select-dropdown-border: $ember-power-select-default-focus-border !default; +$ember-power-select-search-field-border: $ember-power-select-default-border !default; +$ember-power-select-search-field-focus-border: $ember-power-select-default-focus-border !default; + +$ember-power-select-dropdown-top-border: $ember-power-select-dropdown-border !default; +$ember-power-select-dropdown-right-border: $ember-power-select-dropdown-border !default; +$ember-power-select-dropdown-bottom-border: $ember-power-select-dropdown-border !default; +$ember-power-select-dropdown-left-border: $ember-power-select-dropdown-border !default; + +$ember-power-select-dropdown-contiguous-border: none !default; + +// Borders radius +$ember-power-select-default-border-radius: 3px !default; // General border radius +$ember-power-select-trigger-default-border-radius: $ember-power-select-default-border-radius !default; +$ember-power-select-dropdown-default-border-radius: $ember-power-select-default-border-radius !default; + +$ember-power-select-opened-border-radius: 0 !default; // Border radius of the side of the dropdown and the trigger where they touch + +$ember-power-select-search-input-border-radius: 0 !default; +$ember-power-select-multiple-option-border-radius: $ember-power-select-default-border-radius !default; diff --git a/webapp/app/styles/variables/transitions.scss b/webapp/app/styles/variables/transitions.scss new file mode 100644 index 00000000..bd7a4260 --- /dev/null +++ b/webapp/app/styles/variables/transitions.scss @@ -0,0 +1,2 @@ +$transition-speed: 250ms; +$transition-easing: ease; diff --git a/webapp/app/utils/phoenix.js b/webapp/app/utils/phoenix.js new file mode 100644 index 00000000..4870ce72 --- /dev/null +++ b/webapp/app/utils/phoenix.js @@ -0,0 +1,1329 @@ +/** + * Phoenix Channels JavaScript client + * + * ## Socket Connection + * + * A single connection is established to the server and + * channels are multiplexed over the connection. + * Connect to the server using the `Socket` class: + * + * ```javascript + * let socket = new Socket("/socket", {params: {userToken: "123"}}) + * socket.connect() + * ``` + * + * The `Socket` constructor takes the mount point of the socket, + * the authentication params, as well as options that can be found in + * the Socket docs, such as configuring the `LongPoll` transport, and + * heartbeat. + * + * ## Channels + * + * Channels are isolated, concurrent processes on the server that + * subscribe to topics and broker events between the client and server. + * To join a channel, you must provide the topic, and channel params for + * authorization. Here's an example chat room example where `"new_msg"` + * events are listened for, messages are pushed to the server, and + * the channel is joined with ok/error/timeout matches: + * + * ```javascript + * let channel = socket.channel("room:123", {token: roomToken}) + * channel.on("new_msg", msg => console.log("Got message", msg) ) + * $input.onEnter( e => { + * channel.push("new_msg", {body: e.target.val}, 10000) + * .receive("ok", (msg) => console.log("created message", msg) ) + * .receive("error", (reasons) => console.log("create failed", reasons) ) + * .receive("timeout", () => console.log("Networking issue...") ) + * }) + * + * channel.join() + * .receive("ok", ({messages}) => console.log("catching up", messages) ) + * .receive("error", ({reason}) => console.log("failed join", reason) ) + * .receive("timeout", () => console.log("Networking issue. Still waiting...")) + *``` + * + * ## Joining + * + * Creating a channel with `socket.channel(topic, params)`, binds the params to + * `channel.params`, which are sent up on `channel.join()`. + * Subsequent rejoins will send up the modified params for + * updating authorization params, or passing up last_message_id information. + * Successful joins receive an "ok" status, while unsuccessful joins + * receive "error". + * + * ## Duplicate Join Subscriptions + * + * While the client may join any number of topics on any number of channels, + * the client may only hold a single subscription for each unique topic at any + * given time. When attempting to create a duplicate subscription, + * the server will close the existing channel, log a warning, and + * spawn a new channel for the topic. The client will have their + * `channel.onClose` callbacks fired for the existing channel, and the new + * channel join will have its receive hooks processed as normal. + * + * ## Pushing Messages + * + * From the previous example, we can see that pushing messages to the server + * can be done with `channel.push(eventName, payload)` and we can optionally + * receive responses from the push. Additionally, we can use + * `receive("timeout", callback)` to abort waiting for our other `receive` hooks + * and take action after some period of waiting. The default timeout is 10000ms. + * + * + * ## Socket Hooks + * + * Lifecycle events of the multiplexed connection can be hooked into via + * `socket.onError()` and `socket.onClose()` events, ie: + * + * ```javascript + * socket.onError( () => console.log("there was an error with the connection!") ) + * socket.onClose( () => console.log("the connection dropped") ) + * ``` + * + * + * ## Channel Hooks + * + * For each joined channel, you can bind to `onError` and `onClose` events + * to monitor the channel lifecycle, ie: + * + * ```javascript + * channel.onError( () => console.log("there was an error!") ) + * channel.onClose( () => console.log("the channel has gone away gracefully") ) + * ``` + * + * ### onError hooks + * + * `onError` hooks are invoked if the socket connection drops, or the channel + * crashes on the server. In either case, a channel rejoin is attempted + * automatically in an exponential backoff manner. + * + * ### onClose hooks + * + * `onClose` hooks are invoked only in two cases. 1) the channel explicitly + * closed on the server, or 2). The client explicitly closed, by calling + * `channel.leave()` + * + * + * ## Presence + * + * The `Presence` object provides features for syncing presence information + * from the server with the client and handling presences joining and leaving. + * + * ### Syncing initial state from the server + * + * `Presence.syncState` is used to sync the list of presences on the server + * with the client's state. An optional `onJoin` and `onLeave` callback can + * be provided to react to changes in the client's local presences across + * disconnects and reconnects with the server. + * + * `Presence.syncDiff` is used to sync a diff of presence join and leave + * events from the server, as they happen. Like `syncState`, `syncDiff` + * accepts optional `onJoin` and `onLeave` callbacks to react to a user + * joining or leaving from a device. + * + * ### Listing Presences + * + * `Presence.list` is used to return a list of presence information + * based on the local state of metadata. By default, all presence + * metadata is returned, but a `listBy` function can be supplied to + * allow the client to select which metadata to use for a given presence. + * For example, you may have a user online from different devices with + * a metadata status of "online", but they have set themselves to "away" + * on another device. In this case, the app may choose to use the "away" + * status for what appears on the UI. The example below defines a `listBy` + * function which prioritizes the first metadata which was registered for + * each user. This could be the first tab they opened, or the first device + * they came online from: + * + * ```javascript + * let state = {} + * state = Presence.syncState(state, stateFromServer) + * let listBy = (id, {metas: [first, ...rest]}) => { + * first.count = rest.length + 1 // count of this user's presences + * first.id = id + * return first + * } + * let onlineUsers = Presence.list(state, listBy) + * ``` + * + * + * ### Example Usage + * ```javascript + * // detect if user has joined for the 1st time or from another tab/device + * let onJoin = (id, current, newPres) => { + * if(!current){ + * console.log("user has entered for the first time", newPres) + * } else { + * console.log("user additional presence", newPres) + * } + * } + * // detect if user has left from all tabs/devices, or is still present + * let onLeave = (id, current, leftPres) => { + * if(current.metas.length === 0){ + * console.log("user has left from all devices", leftPres) + * } else { + * console.log("user left from a device", leftPres) + * } + * } + * let presences = {} // client's initial empty presence state + * // receive initial presence data from server, sent after join + * myChannel.on("presence_state", state => { + * presences = Presence.syncState(presences, state, onJoin, onLeave) + * displayUsers(Presence.list(presences)) + * }) + * // receive "presence_diff" from server, containing join/leave events + * myChannel.on("presence_diff", diff => { + * presences = Presence.syncDiff(presences, diff, onJoin, onLeave) + * this.setState({users: Presence.list(room.presences, listBy)}) + * }) + * ``` + * @module phoenix + */ + +const VSN = '2.0.0'; +const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3}; +const DEFAULT_TIMEOUT = 10000; +const WS_CLOSE_NORMAL = 1000; +const CHANNEL_STATES = { + closed: 'closed', + errored: 'errored', + joined: 'joined', + joining: 'joining', + leaving: 'leaving' +}; +const CHANNEL_EVENTS = { + close: 'phx_close', + error: 'phx_error', + join: 'phx_join', + reply: 'phx_reply', + leave: 'phx_leave' +}; +const CHANNEL_LIFECYCLE_EVENTS = [ + CHANNEL_EVENTS.close, + CHANNEL_EVENTS.error, + CHANNEL_EVENTS.join, + CHANNEL_EVENTS.reply, + CHANNEL_EVENTS.leave +]; +const TRANSPORTS = { + longpoll: 'longpoll', + websocket: 'websocket' +}; + +/** + * Initializes the Push + * @param {Channel} channel - The Channel + * @param {string} event - The event, for example `"phx_join"` + * @param {Object} payload - The payload, for example `{user_id: 123}` + * @param {number} timeout - The push timeout in milliseconds + */ +class Push { + constructor(channel, event, payload, timeout) { + this.channel = channel; + this.event = event; + this.payload = payload || {}; + this.receivedResp = null; + this.timeout = timeout; + this.timeoutTimer = null; + this.recHooks = []; + this.sent = false; + } + + /** + * + * @param {number} timeout + */ + resend(timeout) { + this.timeout = timeout; + this.reset(); + this.send(); + } + + /** + * + */ + send() { + if (this.hasReceived('timeout')) { + return; + } + this.startTimeout(); + this.sent = true; + this.channel.socket.push({ + topic: this.channel.topic, + event: this.event, + payload: this.payload, + ref: this.ref, + join_ref: this.channel.joinRef() + }); + } + + /** + * + * @param {*} status + * @param {*} callback + */ + receive(status, callback) { + if (this.hasReceived(status)) { + callback(this.receivedResp.response); + } + + this.recHooks.push({status, callback}); + return this; + } + + /** + * @private + */ + reset() { + this.cancelRefEvent(); + this.ref = null; + this.refEvent = null; + this.receivedResp = null; + this.sent = false; + } + + /** + * @private + */ + matchReceive({status, response, ref}) { + this.recHooks.filter(h => h.status === status).forEach(h => h.callback(response)); + } + + /** + * @private + */ + cancelRefEvent() { + if (!this.refEvent) { + return; + } + this.channel.off(this.refEvent); + } + + /** + * @private + */ + cancelTimeout() { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + + /** + * @private + */ + startTimeout() { + if (this.timeoutTimer) { + this.cancelTimeout(); + } + this.ref = this.channel.socket.makeRef(); + this.refEvent = this.channel.replyEventName(this.ref); + + this.channel.on(this.refEvent, payload => { + this.cancelRefEvent(); + this.cancelTimeout(); + this.receivedResp = payload; + this.matchReceive(payload); + }); + + this.timeoutTimer = setTimeout(() => { + this.trigger('timeout', {}); + }, this.timeout); + } + + /** + * @private + */ + hasReceived(status) { + return this.receivedResp && this.receivedResp.status === status; + } + + /** + * @private + */ + trigger(status, response) { + this.channel.trigger(this.refEvent, {status, response}); + } +} + +/** + * + * @param {string} topic + * @param {Object} params + * @param {Socket} socket + */ +export class Channel { + constructor(topic, params, socket) { + this.state = CHANNEL_STATES.closed; + this.topic = topic; + this.params = params || {}; + this.socket = socket; + this.bindings = []; + this.bindingRef = 0; + this.timeout = this.socket.timeout; + this.joinedOnce = false; + this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); + this.pushBuffer = []; + this.rejoinTimer = new Timer(() => this.rejoinUntilConnected(), this.socket.reconnectAfterMs); + this.joinPush.receive('ok', () => { + this.state = CHANNEL_STATES.joined; + this.rejoinTimer.reset(); + this.pushBuffer.forEach(pushEvent => pushEvent.send()); + this.pushBuffer = []; + }); + this.onClose(() => { + this.rejoinTimer.reset(); + this.socket.log('channel', `close ${this.topic} ${this.joinRef()}`); + this.state = CHANNEL_STATES.closed; + this.socket.remove(this); + }); + this.onError(reason => { + if (this.isLeaving() || this.isClosed()) { + return; + } + this.socket.log('channel', `error ${this.topic}`, reason); + this.state = CHANNEL_STATES.errored; + this.rejoinTimer.scheduleTimeout(); + }); + this.joinPush.receive('timeout', () => { + if (!this.isJoining()) { + return; + } + this.socket.log('channel', `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout); + let leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, this.timeout); + leavePush.send(); + this.state = CHANNEL_STATES.errored; + this.joinPush.reset(); + this.rejoinTimer.scheduleTimeout(); + }); + this.on(CHANNEL_EVENTS.reply, (payload, ref) => { + this.trigger(this.replyEventName(ref), payload); + }); + } + + /** + * @private + */ + rejoinUntilConnected() { + this.rejoinTimer.scheduleTimeout(); + if (this.socket.isConnected()) { + this.rejoin(); + } + } + + /** + * Join the channel + * @param {integer} timeout + * @returns {Push} + */ + join(timeout = this.timeout) { + if (this.joinedOnce) { + throw `tried to join multiple times. 'join' can only be called a single time per channel instance`; + } else { + this.joinedOnce = true; + this.rejoin(timeout); + return this.joinPush; + } + } + + /** + * Hook into channel close + * @param {Function} callback + */ + onClose(callback) { + this.on(CHANNEL_EVENTS.close, callback); + } + + /** + * Hook into channel errors + * @param {Function} callback + */ + onError(callback) { + return this.on(CHANNEL_EVENTS.error, reason => callback(reason)); + } + + /** + * Subscribes on channel events + * + * Subscription returns a ref counter, which can be used later to + * unsubscribe the exact event listener + * + * @example + * const ref1 = channel.on("event", do_stuff) + * const ref2 = channel.on("event", do_other_stuff) + * channel.off("event", ref1) + * // Since unsubscription, do_stuff won't fire, + * // while do_other_stuff will keep firing on the "event" + * + * @param {string} event + * @param {Function} callback + */ + on(event, callback) { + let ref = this.bindingRef++; + this.bindings.push({event, ref, callback}); + return ref; + } + + /** + * @param {string} event + * @param {Function} callback + */ + off(event, ref) { + this.bindings = this.bindings.filter(bind => { + return !(bind.event === event && (typeof ref === 'undefined' || ref === bind.ref)); + }); + } + + /** + * @private + */ + canPush() { + return this.socket.isConnected() && this.isJoined(); + } + + /** + * @param {string} event + * @param {Object} payload + * @returns {Push} + */ + push(event, payload, timeout = this.timeout) { + if (!this.joinedOnce) { + throw `tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`; + } + let pushEvent = new Push(this, event, payload, timeout); + if (this.canPush()) { + pushEvent.send(); + } else { + pushEvent.startTimeout(); + this.pushBuffer.push(pushEvent); + } + + return pushEvent; + } + + /** Leaves the channel + * + * Unsubscribes from server events, and + * instructs channel to terminate on server + * + * Triggers onClose() hooks + * + * To receive leave acknowledgements, use the a `receive` + * hook to bind to the server ack, ie: + * + * @example + * channel.leave().receive("ok", () => alert("left!") ) + * + * @param {integer} timeout + * @returns {Push} + */ + leave(timeout = this.timeout) { + this.state = CHANNEL_STATES.leaving; + let onClose = () => { + this.socket.log('channel', `leave ${this.topic}`); + this.trigger(CHANNEL_EVENTS.close, 'leave'); + }; + let leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout); + leavePush.receive('ok', () => onClose()).receive('timeout', () => onClose()); + leavePush.send(); + if (!this.canPush()) { + leavePush.trigger('ok', {}); + } + + return leavePush; + } + + /** + * Overridable message hook + * + * Receives all events for specialized message handling + * before dispatching to the channel callbacks. + * + * Must return the payload, modified or unmodified + * @param {string} event + * @param {Object} payload + * @param {integer} ref + */ + onMessage(event, payload, ref) { + return payload; + } + + /** + * @private + */ + isMember(topic, event, payload, joinRef) { + if (this.topic !== topic) { + return false; + } + let isLifecycleEvent = CHANNEL_LIFECYCLE_EVENTS.indexOf(event) >= 0; + + if (joinRef && isLifecycleEvent && joinRef !== this.joinRef()) { + this.socket.log('channel', 'dropping outdated message', {topic, event, payload, joinRef}); + return false; + } else { + return true; + } + } + + /** + * @private + */ + joinRef() { + return this.joinPush.ref; + } + + /** + * @private + */ + sendJoin(timeout) { + this.state = CHANNEL_STATES.joining; + this.joinPush.resend(timeout); + } + + /** + * @private + */ + rejoin(timeout = this.timeout) { + if (this.isLeaving()) { + return; + } + this.sendJoin(timeout); + } + + /** + * @private + */ + trigger(event, payload, ref, joinRef) { + let handledPayload = this.onMessage(event, payload, ref, joinRef); + if (payload && !handledPayload) { + throw 'channel onMessage callbacks must return the payload, modified or unmodified'; + } + + this.bindings.filter(bind => bind.event === event).map(bind => bind.callback(handledPayload, ref, joinRef || this.joinRef())); + } + + /** + * @private + */ + replyEventName(ref) { + return `chan_reply_${ref}`; + } + + /** + * @private + */ + isClosed() { + return this.state === CHANNEL_STATES.closed; + } + + /** + * @private + */ + isErrored() { + return this.state === CHANNEL_STATES.errored; + } + + /** + * @private + */ + isJoined() { + return this.state === CHANNEL_STATES.joined; + } + + /** + * @private + */ + isJoining() { + return this.state === CHANNEL_STATES.joining; + } + + /** + * @private + */ + isLeaving() { + return this.state === CHANNEL_STATES.leaving; + } +} + +const Serializer = { + encode(msg, callback) { + let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]; + return callback(JSON.stringify(payload)); + }, + + decode(rawPayload, callback) { + let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload); + + return callback({join_ref, ref, topic, event, payload}); + } +}; + +/** Initializes the Socket + * + * + * For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) + * + * @param {string} endPoint - The string WebSocket endpoint, ie, `"ws://example.com/socket"`, + * `"wss://example.com"` + * `"/socket"` (inherited host & protocol) + * @param {Object} opts - Optional configuration + * @param {string} opts.transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. + * + * Defaults to WebSocket with automatic LongPoll fallback. + * @param {Function} opts.encode - The function to encode outgoing messages. + * + * Defaults to JSON: + * + * ```javascript + * (payload, callback) => callback(JSON.stringify(payload)) + * ``` + * + * @param {Function} opts.decode - The function to decode incoming messages. + * + * Defaults to JSON: + * + * ```javascript + * (payload, callback) => callback(JSON.parse(payload)) + * ``` + * + * @param {number} opts.timeout - The default timeout in milliseconds to trigger push timeouts. + * + * Defaults `DEFAULT_TIMEOUT` + * @param {number} opts.heartbeatIntervalMs - The millisec interval to send a heartbeat message + * @param {number} opts.reconnectAfterMs - The optional function that returns the millsec reconnect interval. + * + * Defaults to stepped backoff of: + * + * ```javascript + * function(tries){ + * return [1000, 5000, 10000][tries - 1] || 10000 + * } + * ``` + * @param {Function} opts.logger - The optional function for specialized logging, ie: + * ```javascript + * function(kind, msg, data) { + * console.log(`${kind}: ${msg}`, data) + * } + * ``` + * + * @param {number} opts.longpollerTimeout - The maximum timeout of a long poll AJAX request. + * + * Defaults to 20s (double the server long poll timer). + * + * @param {Object} opts.params - The optional params to pass when connecting + * + * + */ +export class Socket { + constructor(endPoint, opts = {}) { + this.stateChangeCallbacks = {open: [], close: [], error: [], message: []}; + this.channels = []; + this.sendBuffer = []; + this.ref = 0; + this.timeout = opts.timeout || DEFAULT_TIMEOUT; + this.transport = opts.transport || window.WebSocket || LongPoll; + this.defaultEncoder = Serializer.encode; + this.defaultDecoder = Serializer.decode; + if (this.transport !== LongPoll) { + this.encode = opts.encode || this.defaultEncoder; + this.decode = opts.decode || this.defaultDecoder; + } else { + this.encode = this.defaultEncoder; + this.decode = this.defaultDecoder; + } + this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; + this.reconnectAfterMs = + opts.reconnectAfterMs || + function(tries) { + return [1000, 2000, 5000, 10000][tries - 1] || 10000; + }; + this.logger = opts.logger || function() {}; // noop + this.longpollerTimeout = opts.longpollerTimeout || 20000; + this.params = opts.params || {}; + this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`; + this.heartbeatTimer = null; + this.pendingHeartbeatRef = null; + this.reconnectTimer = new Timer(() => { + this.disconnect(() => this.connect()); + }, this.reconnectAfterMs); + } + + /** + * Returns the socket protocol + * + * @returns {string} + */ + protocol() { + return location.protocol.match(/^https/) ? 'wss' : 'ws'; + } + + /** + * The fully qualifed socket url + * + * @returns {string} + */ + endPointURL() { + let uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), {vsn: VSN}); + if (uri.charAt(0) !== '/') { + return uri; + } + if (uri.charAt(1) === '/') { + return `${this.protocol()}:${uri}`; + } + + return `${this.protocol()}://${location.host}${uri}`; + } + + /** + * @param {Function} callback + * @param {integer} code + * @param {string} reason + */ + disconnect(callback, code, reason) { + if (this.conn) { + this.conn.onclose = function() {}; // noop + if (code) { + this.conn.close(code, reason || ''); + } else { + this.conn.close(); + } + this.conn = null; + } + callback && callback(); + } + + /** + * + * @param {Object} params - The params to send when connecting, for example `{user_id: userToken}` + */ + connect(params) { + if (params) { + console && console.log('passing params to connect is deprecated. Instead pass :params to the Socket constructor'); + this.params = params; + } + if (this.conn) { + return; + } + + this.conn = new this.transport(this.endPointURL()); + this.conn.timeout = this.longpollerTimeout; + this.conn.onopen = () => this.onConnOpen(); + this.conn.onerror = error => this.onConnError(error); + this.conn.onmessage = event => this.onConnMessage(event); + this.conn.onclose = event => this.onConnClose(event); + } + + /** + * Logs the message. Override `this.logger` for specialized logging. noops by default + * @param {string} kind + * @param {string} msg + * @param {Object} data + */ + log(kind, msg, data) { + this.logger(kind, msg, data); + } + + /** + * Registers callbacks for connection open events + * + * @example socket.onOpen(function(){ console.info("the socket was opened") }) + * + * @param {Function} callback + */ + onOpen(callback) { + this.stateChangeCallbacks.open.push(callback); + } + + /** + * Registers callbacks for connection close events + * @param {Function} callback + */ + onClose(callback) { + this.stateChangeCallbacks.close.push(callback); + } + + /** + * Registers callbacks for connection error events + * + * @example socket.onError(function(error){ alert("An error occurred") }) + * + * @param {Function} callback + */ + onError(callback) { + this.stateChangeCallbacks.error.push(callback); + } + + /** + * Registers callbacks for connection message events + * @param {Function} callback + */ + onMessage(callback) { + this.stateChangeCallbacks.message.push(callback); + } + + /** + * @private + */ + onConnOpen() { + this.log('transport', `connected to ${this.endPointURL()}`); + this.flushSendBuffer(); + this.reconnectTimer.reset(); + if (!this.conn.skipHeartbeat) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs); + } + this.stateChangeCallbacks.open.forEach(callback => callback()); + } + + /** + * @private + */ + onConnClose(event) { + this.log('transport', 'close', event); + this.triggerChanError(); + clearInterval(this.heartbeatTimer); + this.reconnectTimer.scheduleTimeout(); + this.stateChangeCallbacks.close.forEach(callback => callback(event)); + } + + /** + * @private + */ + onConnError(error) { + this.log('transport', error); + this.triggerChanError(); + this.stateChangeCallbacks.error.forEach(callback => callback(error)); + } + + /** + * @private + */ + triggerChanError() { + this.channels.forEach(channel => channel.trigger(CHANNEL_EVENTS.error)); + } + + /** + * @returns {string} + */ + connectionState() { + switch (this.conn && this.conn.readyState) { + case SOCKET_STATES.connecting: + return 'connecting'; + case SOCKET_STATES.open: + return 'open'; + case SOCKET_STATES.closing: + return 'closing'; + default: + return 'closed'; + } + } + + /** + * @returns {boolean} + */ + isConnected() { + return this.connectionState() === 'open'; + } + + /** + * @param {Channel} + */ + remove(channel) { + this.channels = this.channels.filter(c => c.joinRef() !== channel.joinRef()); + } + + /** + * Initiates a new channel for the given topic + * + * @param {string} topic + * @param {Object} chanParams - Parameters for the channel + * @returns {Channel} + */ + channel(topic, chanParams = {}) { + let chan = new Channel(topic, chanParams, this); + this.channels.push(chan); + return chan; + } + + /** + * @param {Object} data + */ + push(data) { + let {topic, event, payload, ref, join_ref} = data; + let callback = () => { + this.encode(data, result => { + this.conn.send(result); + }); + }; + this.log('push', `${topic} ${event} (${join_ref}, ${ref})`, payload); + if (this.isConnected()) { + callback(); + } else { + this.sendBuffer.push(callback); + } + } + + /** + * Return the next message ref, accounting for overflows + * @returns {string} + */ + makeRef() { + let newRef = this.ref + 1; + if (newRef === this.ref) { + this.ref = 0; + } else { + this.ref = newRef; + } + + return this.ref.toString(); + } + + sendHeartbeat() { + if (!this.isConnected()) { + return; + } + if (this.pendingHeartbeatRef) { + this.pendingHeartbeatRef = null; + this.log('transport', 'heartbeat timeout. Attempting to re-establish connection'); + this.conn.close(WS_CLOSE_NORMAL, 'hearbeat timeout'); + return; + } + this.pendingHeartbeatRef = this.makeRef(); + this.push({topic: 'phoenix', event: 'heartbeat', payload: {}, ref: this.pendingHeartbeatRef}); + } + + flushSendBuffer() { + if (this.isConnected() && this.sendBuffer.length > 0) { + this.sendBuffer.forEach(callback => callback()); + this.sendBuffer = []; + } + } + + onConnMessage(rawMessage) { + this.decode(rawMessage.data, msg => { + let {topic, event, payload, ref, join_ref} = msg; + if (ref && ref === this.pendingHeartbeatRef) { + this.pendingHeartbeatRef = null; + } + + this.log('receive', `${payload.status || ''} ${topic} ${event} ${(ref && '(' + ref + ')') || ''}`, payload); + this.channels + .filter(channel => channel.isMember(topic, event, payload, join_ref)) + .forEach(channel => channel.trigger(event, payload, ref, join_ref)); + this.stateChangeCallbacks.message.forEach(callback => callback(msg)); + }); + } +} + +export class LongPoll { + constructor(endPoint) { + this.endPoint = null; + this.token = null; + this.skipHeartbeat = true; + this.onopen = function() {}; // noop + this.onerror = function() {}; // noop + this.onmessage = function() {}; // noop + this.onclose = function() {}; // noop + this.pollEndpoint = this.normalizeEndpoint(endPoint); + this.readyState = SOCKET_STATES.connecting; + + this.poll(); + } + + normalizeEndpoint(endPoint) { + return endPoint + .replace('ws://', 'http://') + .replace('wss://', 'https://') + .replace(new RegExp('(.*)/' + TRANSPORTS.websocket), '$1/' + TRANSPORTS.longpoll); + } + + endpointURL() { + return Ajax.appendParams(this.pollEndpoint, {token: this.token}); + } + + closeAndRetry() { + this.close(); + this.readyState = SOCKET_STATES.connecting; + } + + ontimeout() { + this.onerror('timeout'); + this.closeAndRetry(); + } + + poll() { + if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { + return; + } + + Ajax.request('GET', this.endpointURL(), 'application/json', null, this.timeout, this.ontimeout.bind(this), resp => { + if (resp) { + var {status, token, messages} = resp; + this.token = token; + } else { + var status = 0; + } + + switch (status) { + case 200: + messages.forEach(msg => this.onmessage({data: msg})); + this.poll(); + break; + case 204: + this.poll(); + break; + case 410: + this.readyState = SOCKET_STATES.open; + this.onopen(); + this.poll(); + break; + case 0: + case 500: + this.onerror(); + this.closeAndRetry(); + break; + default: + throw `unhandled poll status ${status}`; + } + }); + } + + send(body) { + Ajax.request('POST', this.endpointURL(), 'application/json', body, this.timeout, this.onerror.bind(this, 'timeout'), resp => { + if (!resp || resp.status !== 200) { + this.onerror(resp && resp.status); + this.closeAndRetry(); + } + }); + } + + close(code, reason) { + this.readyState = SOCKET_STATES.closed; + this.onclose(); + } +} + +export class Ajax { + static request(method, endPoint, accept, body, timeout, ontimeout, callback) { + if (window.XDomainRequest) { + let req = new XDomainRequest(); // IE8, IE9 + this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); + } else { + let req = window.XMLHttpRequest + ? new window.XMLHttpRequest() // IE7+, Firefox, Chrome, Opera, Safari + : new ActiveXObject('Microsoft.XMLHTTP'); // IE6, IE5 + this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); + } + } + + static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { + req.timeout = timeout; + req.open(method, endPoint); + req.onload = () => { + let response = this.parseJSON(req.responseText); + callback && callback(response); + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + + // Work around bug in IE9 that requires an attached onprogress handler + req.onprogress = () => {}; + + req.send(body); + } + + static xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { + req.open(method, endPoint, true); + req.timeout = timeout; + req.setRequestHeader('Content-Type', accept); + req.onerror = () => { + callback && callback(null); + }; + req.onreadystatechange = () => { + if (req.readyState === this.states.complete && callback) { + let response = this.parseJSON(req.responseText); + callback(response); + } + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + + req.send(body); + } + + static parseJSON(resp) { + if (!resp || resp === '') { + return null; + } + + try { + return JSON.parse(resp); + } catch (e) { + console && console.log('failed to parse JSON response', resp); + return null; + } + } + + static serialize(obj, parentKey) { + let queryStr = []; + for (var key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + let paramKey = parentKey ? `${parentKey}[${key}]` : key; + let paramVal = obj[key]; + if (typeof paramVal === 'object') { + queryStr.push(this.serialize(paramVal, paramKey)); + } else { + queryStr.push(encodeURIComponent(paramKey) + '=' + encodeURIComponent(paramVal)); + } + } + return queryStr.join('&'); + } + + static appendParams(url, params) { + if (Object.keys(params).length === 0) { + return url; + } + + let prefix = url.match(/\?/) ? '&' : '?'; + return `${url}${prefix}${this.serialize(params)}`; + } +} + +Ajax.states = {complete: 4}; + +export var Presence = { + syncState(currentState, newState, onJoin, onLeave) { + let state = this.clone(currentState); + let joins = {}; + let leaves = {}; + + this.map(state, (key, presence) => { + if (!newState[key]) { + leaves[key] = presence; + } + }); + this.map(newState, (key, newPresence) => { + let currentPresence = state[key]; + if (currentPresence) { + let newRefs = newPresence.metas.map(m => m.phx_ref); + let curRefs = currentPresence.metas.map(m => m.phx_ref); + let joinedMetas = newPresence.metas.filter(m => curRefs.indexOf(m.phx_ref) < 0); + let leftMetas = currentPresence.metas.filter(m => newRefs.indexOf(m.phx_ref) < 0); + if (joinedMetas.length > 0) { + joins[key] = newPresence; + joins[key].metas = joinedMetas; + } + if (leftMetas.length > 0) { + leaves[key] = this.clone(currentPresence); + leaves[key].metas = leftMetas; + } + } else { + joins[key] = newPresence; + } + }); + return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave); + }, + + syncDiff(currentState, {joins, leaves}, onJoin, onLeave) { + let state = this.clone(currentState); + if (!onJoin) { + onJoin = function() {}; + } + if (!onLeave) { + onLeave = function() {}; + } + + this.map(joins, (key, newPresence) => { + let currentPresence = state[key]; + state[key] = newPresence; + if (currentPresence) { + let joinedRefs = state[key].metas.map(m => m.phx_ref); + let curMetas = currentPresence.metas.filter(m => joinedRefs.indexOf(m.phx_ref) < 0); + state[key].metas.unshift(...curMetas); + } + onJoin(key, currentPresence, newPresence); + }); + this.map(leaves, (key, leftPresence) => { + let currentPresence = state[key]; + if (!currentPresence) { + return; + } + let refsToRemove = leftPresence.metas.map(m => m.phx_ref); + currentPresence.metas = currentPresence.metas.filter(p => { + return refsToRemove.indexOf(p.phx_ref) < 0; + }); + onLeave(key, currentPresence, leftPresence); + if (currentPresence.metas.length === 0) { + delete state[key]; + } + }); + return state; + }, + + list(presences, chooser) { + if (!chooser) { + chooser = function(key, pres) { + return pres; + }; + } + + return this.map(presences, (key, presence) => { + return chooser(key, presence); + }); + }, + + // private + + map(obj, func) { + return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key])); + }, + + clone(obj) { + return JSON.parse(JSON.stringify(obj)); + } +}; + +/** + * + * Creates a timer that accepts a `timerCalc` function to perform + * calculated timeout retries, such as exponential backoff. + * + * @example + * let reconnectTimer = new Timer(() => this.connect(), function(tries){ + * return [1000, 5000, 10000][tries - 1] || 10000 + * }) + * reconnectTimer.scheduleTimeout() // fires after 1000 + * reconnectTimer.scheduleTimeout() // fires after 5000 + * reconnectTimer.reset() + * reconnectTimer.scheduleTimeout() // fires after 1000 + * + * @param {Function} callback + * @param {Function} timerCalc + */ +class Timer { + constructor(callback, timerCalc) { + this.callback = callback; + this.timerCalc = timerCalc; + this.timer = null; + this.tries = 0; + } + + reset() { + this.tries = 0; + clearTimeout(this.timer); + } + + /** + * Cancels any previous scheduleTimeout and schedules callback + */ + scheduleTimeout() { + clearTimeout(this.timer); + + this.timer = setTimeout(() => { + this.tries = this.tries + 1; + this.callback(); + }, this.timerCalc(this.tries + 1)); + } +} diff --git a/webapp/config/environment.js b/webapp/config/environment.js new file mode 100644 index 00000000..604914fc --- /dev/null +++ b/webapp/config/environment.js @@ -0,0 +1,97 @@ +/* eslint-env node */ + +module.exports = function(environment) { + const wsHost = process.env.API_WS_HOST || 'ws://localhost:4000'; + const host = process.env.API_HOST || 'http://localhost:4000'; + + const ENV = { + modulePrefix: 'accent-webapp', + podModulePrefix: 'accent-webapp/pods', + environment, + rootURL: '/', + locationType: 'auto', + + i18n: { + defaultLocale: 'en' + }, + + flashMessageDefaults: { + // flash message defaults + timeout: 5000, + destroyOnClick: false, + extendedTimeout: 300, + priority: 200, + sticky: false, + showProgress: false, + + // service defaults + type: 'info', + types: ['info', 'success', 'error', 'socket'], + injectionFactories: [] + }, + + EmberENV: { + EXTEND_PROTOTYPES: false, + LOG_VERSION: false + }, + + APP: { + LOCAL_STORAGE: { + SESSION_NAMESPACE: 'accent-session' + } + }, + + GOOGLE_API: { + CLIENT_ID: process.env.GOOGLE_API_CLIENT_ID + }, + + GOOGLE_LOGIN_ENABLED: Boolean(process.env.GOOGLE_API_CLIENT_ID), + DUMMY_LOGIN_ENABLED: !Boolean(process.env.GOOGLE_API_CLIENT_ID), + + SENTRY: { + DSN: process.env.WEBAPP_SENTRY_DSN + }, + + API: { + WS_HOST: wsHost, + HOST: host, + AUTHENTICATION_PATH: `${host}/auth`, + PROJECT_PATH: `${host}/projects/{0}`, + SYNC_PEEK_PROJECT_PATH: `${host}/sync/peek?project_id={0}&language={1}`, + SYNC_PROJECT_PATH: `${host}/sync?project_id={0}&language={1}`, + MERGE_PEEK_PROJECT_PATH: `${host}/merge/peek?project_id={0}&language={1}&merge_type={2}`, + MERGE_REVISION_PATH: `${host}/merge?project_id={0}&language={1}&merge_type={2}`, + EXPORT_DOCUMENT: `${host}/export`, + PERCENTAGE_REVIEWED_BADGE_SVG_PROJECT_PATH: `${host}/{0}/percentage_reviewed_badge.svg` + }, + + contentSecurityPolicy: { + 'default-src': "'none'", + 'script-src': "'self' 'unsafe-inline' 'unsafe-eval' apis.google.com cdn.ravenjs.com", + // Allow fonts to be loaded from http://fonts.gstatic.com + 'font-src': "'self' http://fonts.gstatic.com", + // Allow data (ajax/websocket) + 'connect-src': `'self' https://www.googleapis.com ${wsHost} ${host} https://sentry.io`, + 'img-src': '*', + // Allow inline styles and loaded CSS from http://fonts.googleapis.com + 'style-src': "'self' 'unsafe-inline' http://fonts.googleapis.com", + 'media-src': "'self'", + 'frame-src': 'accounts.google.com' + } + + }; + + if (environment === 'test') { + ENV.baseURL = '/'; + ENV.locationType = 'none'; + + ENV.APP.LOG_ACTIVE_GENERATION = false; + ENV.APP.LOG_VIEW_LOOKUPS = false; + + ENV.APP.rootElement = '#ember-testing'; + + ENV.APP.LOCAL_STORAGE.SESSION_NAMESPACE = 'accent-session-test'; + } + + return ENV; +}; diff --git a/webapp/ember-cli-build.js b/webapp/ember-cli-build.js new file mode 100644 index 00000000..939df684 --- /dev/null +++ b/webapp/ember-cli-build.js @@ -0,0 +1,46 @@ +/* eslint-env node */ +/* eslint-env es6:false */ +/* eslint no-var:0 */ + +var EmberApp = require('ember-cli/lib/broccoli/ember-app'); + +module.exports = function(defaults) { + var app = new EmberApp(defaults, { + hinting: false, + vendorFiles: { + 'jquery.js': null, + }, + autoprefixer: { + browsers: [ + 'ie >= 10', + 'last 2 versions' + ] + }, + babel: { + sourceMaps: 'inline', + plugins: ['transform-object-rest-spread'] + }, + 'ember-cli-babel': { + includePolyfill: true + }, + svg: { + paths: [ + 'public' + ] + }, + nodeAssets: { + diff: { + srcDir: 'dist', + vendor: ['diff.js'], + import: ['diff.js'] + }, + 'spin.js': { + srcDir: '', + vendor: ['spin.js'], + import: ['spin.js'] + } + } + }); + + return app.toTree(); +}; diff --git a/webapp/package-lock.json b/webapp/package-lock.json new file mode 100644 index 00000000..d033d871 --- /dev/null +++ b/webapp/package-lock.json @@ -0,0 +1,13046 @@ +{ + "name": "accent-webapp", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@ember/test-helpers": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/@ember/test-helpers/-/test-helpers-0.7.18.tgz", + "integrity": "sha512-AHsjhRs8AN22W/QduxsvDzGT7lHuRhfveyfpa4HVTA5bGTM/ryO8sgHyDmdjHobrnmA7DLWj5YH/VMUoc2i49Q==", + "requires": { + "broccoli-funnel": "2.0.1", + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars-inline-precompile": "1.0.2" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + } + } + }, + "@glimmer/di": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@glimmer/di/-/di-0.2.0.tgz", + "integrity": "sha1-c7/Upu5BSKgL8JLopdKbysnUzn4=" + }, + "@glimmer/resolver": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@glimmer/resolver/-/resolver-0.4.3.tgz", + "integrity": "sha512-UhX6vlZbWRMq6pCquSC3wfWLM9kO0PhQPD1dZ3XnyZkmsvEE94Cq+EncA9JalUuevKoJrfUFRvrZ0xaz+yar3g==", + "requires": { + "@glimmer/di": "0.2.0" + } + }, + "@sinonjs/formatio": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", + "requires": { + "samsam": "1.3.0" + } + }, + "@types/async": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/@types/async/-/async-2.0.47.tgz", + "integrity": "sha512-/mQMARXVSuGbwOFFBKA4s0qRKtOaaTgnllp3qU4sMzDVGGAroPblyd529yBALnK/WEY8nHyRGx0/RFUDmhpVmQ==", + "optional": true + }, + "@types/node": { + "version": "9.4.7", + "resolved": "http://registry.npmjs.org/@types/node/-/node-9.4.7.tgz", + "integrity": "sha512-4Ba90mWNx8ddbafuyGGwjkZMigi+AWfYLSDCpovwsE63ia8w93r3oJ8PIAQc3y8U+XHcnMOHPIzNe3o438Ywcw==" + }, + "@types/zen-observable": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.5.3.tgz", + "integrity": "sha512-aDvGDAHcVfUqNmd8q4//cHAP+HGxsbChbBbuk3+kMVk5TTxfWLpQWvVN3+UPjohLnwMYN7jr6BWNn2cYNqdm7g==" + }, + "JSONStream": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.2.tgz", + "integrity": "sha1-wQI3G27Dp887hHygDCC7D85Mbeo=", + "requires": { + "jsonparse": "1.3.1", + "through": "2.3.8" + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "requires": { + "mime-types": "2.1.18", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.5.3.tgz", + "integrity": "sha512-jd5MkIUlbbmb07nXH0DT3y7rDVtkzDi4XZOUVWAer8ajmF/DTSSbl5oNFyDOl/OXA33Bl79+ypHhl2pN20VeOQ==" + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "3.3.0" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "acorn-node": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.3.0.tgz", + "integrity": "sha512-efP54n3d1aLfjL2UMdaXa6DsswwzJeI5rqhbFvXMrKiJ6eJFpf+7R0zN7t8IC+XKn2YOAFAv6xbBNgHUkoHWLw==", + "requires": { + "acorn": "5.5.3", + "xtend": "4.0.1" + } + }, + "after": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.1.tgz", + "integrity": "sha1-q11PuIP1loFtNRX495HAr0ht1ic=" + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "4.6.0", + "fast-deep-equal": "1.1.0", + "fast-json-stable-stringify": "2.0.0", + "json-schema-traverse": "0.3.1" + } + }, + "ajv-keywords": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", + "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "requires": { + "kind-of": "3.2.2", + "longest": "1.0.1", + "repeat-string": "1.6.1" + } + }, + "alter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/alter/-/alter-0.2.0.tgz", + "integrity": "sha1-x1iICGF1cgNKrmJICvJrHU0cs80=", + "requires": { + "stable": "0.1.6" + } + }, + "amd-name-resolver": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/amd-name-resolver/-/amd-name-resolver-0.0.7.tgz", + "integrity": "sha1-gUMBrf6KLxCfboTV6TUZbvtmlhU=", + "requires": { + "ensure-posix-path": "1.0.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + }, + "ansicolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.2.1.tgz", + "integrity": "sha1-vgiVmQl7dKXJxKhKDNvNtivYeu8=" + }, + "anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "requires": { + "micromatch": "2.3.11", + "normalize-path": "2.1.1" + } + }, + "apollo-boost": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/apollo-boost/-/apollo-boost-0.1.3.tgz", + "integrity": "sha512-a7vlKjbrlWnwE6ANsd8mnTZnt9NfnJ4+xKYa0gdmYqv8X+3SvXNbC3GGhV1E/WtLgGwKiHdQy4CH6qZV/0pkBg==", + "requires": { + "apollo-cache-inmemory": "1.1.11", + "apollo-client": "2.2.7", + "apollo-link": "1.2.1", + "apollo-link-error": "1.0.7", + "apollo-link-http": "1.5.3", + "apollo-link-state": "0.4.1", + "graphql-tag": "2.8.0" + }, + "dependencies": { + "apollo-client": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.2.7.tgz", + "integrity": "sha512-vvpw21HI7xQV6o44oeB7WH9X9+7wXHsZk/LXxeqIM+Nl2Uma68YhOqR6WMatFCGmFevJig1yBEKMeSelI8mhRw==", + "requires": { + "@types/async": "2.0.47", + "@types/zen-observable": "0.5.3", + "apollo-cache": "1.1.6", + "apollo-link": "1.2.1", + "apollo-link-dedup": "1.0.8", + "apollo-utilities": "1.0.10", + "symbol-observable": "1.2.0", + "zen-observable": "0.7.1" + } + } + } + }, + "apollo-cache": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.1.6.tgz", + "integrity": "sha512-Xf8O/w8ZXakACUXzxUIWVAHImVNEYeSi3qevMig5NCcMK2xaEQoTWj5JsMTqv5/JGUrskOrx44UjwSzV94nzJg==", + "requires": { + "apollo-utilities": "1.0.10" + } + }, + "apollo-cache-inmemory": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.1.11.tgz", + "integrity": "sha512-5GUbnOtVdh6bbYcQuEXeOdWJC1qL1LLhoJWreHQcR7rG8RB230J9bxsfEmPEFMjAfkBCeYKUIlE7kZzu/8q1UQ==", + "requires": { + "apollo-cache": "1.1.6", + "apollo-utilities": "1.0.10", + "graphql-anywhere": "4.1.7" + }, + "dependencies": { + "graphql-anywhere": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/graphql-anywhere/-/graphql-anywhere-4.1.7.tgz", + "integrity": "sha512-sgp/p1bWVZ/74BAAi6iy4MF/k7h4tfmXM5LV9c+EsImCfzOROJa0aOIwHOdLrtRhxq0vmw1sO6aSDnxJQpTzHA==", + "requires": { + "apollo-utilities": "1.0.10" + } + } + } + }, + "apollo-link": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.1.tgz", + "integrity": "sha512-6Ghf+j3cQLCIvjXd2dJrLw+16HZbWbwmB1qlTc41BviB2hv+rK1nJr17Y9dWK0UD4p3i9Hfddx3tthpMKrueHg==", + "requires": { + "@types/node": "9.4.7", + "apollo-utilities": "1.0.10", + "zen-observable-ts": "0.8.8" + }, + "dependencies": { + "zen-observable-ts": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.8.tgz", + "integrity": "sha512-oGjFvBbAA94uh/HvAwJDwMHtNq4lZRtupJx8XsyreOTYvH8x1ef9hIeH/M+IqiAXtNpglq/Klh5rbpYWEeRSOQ==", + "requires": { + "zen-observable": "0.7.1" + } + } + } + }, + "apollo-link-dedup": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/apollo-link-dedup/-/apollo-link-dedup-1.0.8.tgz", + "integrity": "sha512-M4p8yzX7sSID+R/qxz3ti+IGeRi6yuyEEG8Apd6wQwsefG83LTzhMJYO7Z08Yg4tOFUfqbp32RW9ZjwmyrcBVg==", + "requires": { + "apollo-link": "1.2.1" + } + }, + "apollo-link-error": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/apollo-link-error/-/apollo-link-error-1.0.7.tgz", + "integrity": "sha512-y+5NzYSj1HXbxAGP05rw0EsnCy6LtJvoYW5DtVxI8QLFwpqJv8Z5VNsGdpstjLdXK1oqp7D20DDz4932WXhdmQ==", + "requires": { + "apollo-link": "1.2.1" + } + }, + "apollo-link-http": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.3.tgz", + "integrity": "sha512-t49wBXzdXpVdiqr82Lj0x6hORVVtIQQ5hLfVKjmQXiA/uv0o0Np8mAcOBZRYJvpVRHkaLvv5w/4MRbc8h5UGuQ==", + "requires": { + "apollo-link": "1.2.1", + "apollo-link-http-common": "0.2.3" + } + }, + "apollo-link-http-common": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.3.tgz", + "integrity": "sha512-gZ5xp2ZlX9Ie0HR/bVDxD4073UAMdZC/QhPQifh/AV6YCOTuj3ZkaAf89KnuQCifWVXO3yXD/53SWm7VrsjCdQ==", + "requires": { + "apollo-link": "1.2.1" + } + }, + "apollo-link-state": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/apollo-link-state/-/apollo-link-state-0.4.1.tgz", + "integrity": "sha512-69/til4ENfl/Fvf7br2xSsLSBcxcXPbOHVNkzLLejvUZickl93HLO4/fO+uvoBi4dCYRgN17Zr8FwI41ueRx0g==", + "requires": { + "apollo-utilities": "1.0.10", + "graphql-anywhere": "4.1.7" + }, + "dependencies": { + "graphql-anywhere": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/graphql-anywhere/-/graphql-anywhere-4.1.7.tgz", + "integrity": "sha512-sgp/p1bWVZ/74BAAi6iy4MF/k7h4tfmXM5LV9c+EsImCfzOROJa0aOIwHOdLrtRhxq0vmw1sO6aSDnxJQpTzHA==", + "requires": { + "apollo-utilities": "1.0.10" + } + } + } + }, + "apollo-utilities": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.0.10.tgz", + "integrity": "sha512-m3cjHeCWFevkDMDARWD7ZdiW3oWFX6e0/tCLykvcIgNRf62I1nqHfJohRKGPu58/QUY8c4IZSAj5NS/Foh82qQ==" + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz", + "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", + "requires": { + "delegates": "1.0.0", + "readable-stream": "2.3.5" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "1.0.3" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + } + } + }, + "arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", + "requires": { + "arr-flatten": "1.1.0" + } + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==" + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=" + }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=" + }, + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=" + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=" + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=" + }, + "array-to-error": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-to-error/-/array-to-error-1.1.1.tgz", + "integrity": "sha1-1ogSkm0UCXogVXmmZ+6vGFakTAc=", + "requires": { + "array-to-sentence": "1.1.0" + } + }, + "array-to-sentence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-to-sentence/-/array-to-sentence-1.1.0.tgz", + "integrity": "sha1-yASVba+lMjJJWyBalFJ1OiWNOfw=" + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "1.0.3" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=" + }, + "arraybuffer.slice": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz", + "integrity": "sha1-8zshWfBTKj8xB6JywMz70a0peco=" + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "requires": { + "bn.js": "4.11.8", + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "requires": { + "util": "0.10.3" + } + }, + "assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=" + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=" + }, + "ast-traverse": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ast-traverse/-/ast-traverse-0.1.1.tgz", + "integrity": "sha1-ac8rg4bxnc2hux4F1o/jWdiJfeY=" + }, + "ast-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.6.tgz", + "integrity": "sha1-ECyenpAF0+fjgpvwxPok7oYu6bk=" + }, + "astw": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/astw/-/astw-2.2.0.tgz", + "integrity": "sha1-e9QXhNMkk5h66yOba04cV6hzuRc=", + "requires": { + "acorn": "4.0.13" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + } + } + }, + "async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", + "requires": { + "lodash": "4.17.5" + } + }, + "async-disk-cache": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-disk-cache/-/async-disk-cache-1.3.3.tgz", + "integrity": "sha512-GyaWSbDAZCltxSobtj1m1ptXa0+zSdjWs3sM4IqnvhoRwMDHW5786sXQ1RiXbR3ZGuQe6NXMB4N0vUmW163cew==", + "requires": { + "debug": "2.6.9", + "heimdalljs": "0.2.5", + "istextorbinary": "2.1.0", + "mkdirp": "0.5.1", + "rimraf": "2.6.2", + "rsvp": "3.6.2", + "username-sync": "1.0.1" + } + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" + }, + "async-promise-queue": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/async-promise-queue/-/async-promise-queue-1.0.4.tgz", + "integrity": "sha512-GQ5X3DT+TefYuFPHdvIPXFTlKnh39U7dwtl+aUBGeKjMea9nBpv3c91DXgeyBQmY07vQ97f3Sr9XHqkamEameQ==", + "requires": { + "async": "2.6.0", + "debug": "2.6.9" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "atob": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.0.3.tgz", + "integrity": "sha1-GcenYEc3dEaPILLS0DNyrX1Mv10=" + }, + "autoprefixer": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-7.2.6.tgz", + "integrity": "sha512-Iq8TRIB+/9eQ8rbGhcP7ct5cYb/3qjNYAR2SnzLCEcwF6rvVOax8+9+fccgXk4bEhQGjOZd5TLhsksmAdsbGqQ==", + "requires": { + "browserslist": "2.11.3", + "caniuse-lite": "1.0.30000815", + "normalize-range": "0.1.2", + "num2fraction": "1.2.2", + "postcss": "6.0.19", + "postcss-value-parser": "3.3.0" + } + }, + "aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=" + }, + "aws4": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "requires": { + "chalk": "1.1.3", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + } + }, + "babel-core": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz", + "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=", + "requires": { + "babel-code-frame": "6.26.0", + "babel-generator": "6.26.1", + "babel-helpers": "6.24.1", + "babel-messages": "6.23.0", + "babel-register": "6.26.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "convert-source-map": "1.5.1", + "debug": "2.6.9", + "json5": "0.5.1", + "lodash": "4.17.5", + "minimatch": "3.0.4", + "path-is-absolute": "1.0.1", + "private": "0.1.8", + "slash": "1.0.0", + "source-map": "0.5.7" + }, + "dependencies": { + "convert-source-map": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", + "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=" + } + } + }, + "babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "requires": { + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "detect-indent": "4.0.0", + "jsesc": "1.3.0", + "lodash": "4.17.5", + "source-map": "0.5.7", + "trim-right": "1.0.1" + }, + "dependencies": { + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=" + } + } + }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "requires": { + "babel-helper-explode-assignable-expression": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "requires": { + "babel-helper-hoist-variables": "6.24.1", + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.5" + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "requires": { + "babel-helper-get-function-arity": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.5" + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "requires": { + "babel-helper-optimise-call-expression": "6.24.1", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-constant-folding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-constant-folding/-/babel-plugin-constant-folding-1.0.1.tgz", + "integrity": "sha1-g2HTZMmORJw2kr26Ue/whEKQqo4=" + }, + "babel-plugin-dead-code-elimination": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-dead-code-elimination/-/babel-plugin-dead-code-elimination-1.0.2.tgz", + "integrity": "sha1-X3xFEnTc18zNv7s+C4XdKBIfD2U=" + }, + "babel-plugin-debug-macros": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/babel-plugin-debug-macros/-/babel-plugin-debug-macros-0.1.11.tgz", + "integrity": "sha512-hZw5qNNGAR02Y+yBUrtsnJHh8OXavkayPRqKGAXnIm4t5rWVpj3ArwsC7TWdpZsBguQvHAeyTxZ7s23yY60HHg==", + "requires": { + "semver": "5.5.0" + } + }, + "babel-plugin-ember-modules-api-polyfill": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-2.3.0.tgz", + "integrity": "sha512-cv5ZimF5X52uW7Ul83UUxtsFZE6rZYkMv6qWnAeiDLT1/KtpVrTkJpwzDlvJ/FhKJZ43ih4GbFbhuhBKKT7vIw==", + "requires": { + "ember-rfc176-data": "0.3.1" + } + }, + "babel-plugin-eval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-eval/-/babel-plugin-eval-1.0.1.tgz", + "integrity": "sha1-ovrtJc5r5preS/7CY/cBaRlZUNo=" + }, + "babel-plugin-htmlbars-inline-precompile": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-0.2.3.tgz", + "integrity": "sha1-zTZeJ4r0Cb+mvncExDVL7udCRGs=" + }, + "babel-plugin-inline-environment-variables": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-inline-environment-variables/-/babel-plugin-inline-environment-variables-1.0.1.tgz", + "integrity": "sha1-H1jOkSB61qgmqL9kX6/mj/X+P/4=" + }, + "babel-plugin-jscript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/babel-plugin-jscript/-/babel-plugin-jscript-1.0.4.tgz", + "integrity": "sha1-jzQsOCduh6R9X6CovT1etsytj8w=" + }, + "babel-plugin-member-expression-literals": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-member-expression-literals/-/babel-plugin-member-expression-literals-1.0.1.tgz", + "integrity": "sha1-zF7bD6qNyScXDnTW0cAkQAIWJNM=" + }, + "babel-plugin-property-literals": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-property-literals/-/babel-plugin-property-literals-1.0.1.tgz", + "integrity": "sha1-AlIwGQAZKYCxwRjv6kjOk6q4MzY=" + }, + "babel-plugin-proto-to-assign": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/babel-plugin-proto-to-assign/-/babel-plugin-proto-to-assign-1.0.4.tgz", + "integrity": "sha1-xJ56/QL1d7xNoF6i3wAiUM980SM=", + "requires": { + "lodash": "3.10.1" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + } + } + }, + "babel-plugin-react-constant-elements": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/babel-plugin-react-constant-elements/-/babel-plugin-react-constant-elements-1.0.3.tgz", + "integrity": "sha1-lGc26DeEKcvDSdz/YvUcFDs041o=" + }, + "babel-plugin-react-display-name": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/babel-plugin-react-display-name/-/babel-plugin-react-display-name-1.0.3.tgz", + "integrity": "sha1-dU/jiSboQkpOexWrbqYTne4FFPw=" + }, + "babel-plugin-remove-console": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-remove-console/-/babel-plugin-remove-console-1.0.1.tgz", + "integrity": "sha1-2PJFVsOgUAXUKqqv0neH9T/wE6c=" + }, + "babel-plugin-remove-debugger": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-remove-debugger/-/babel-plugin-remove-debugger-1.0.1.tgz", + "integrity": "sha1-/S6jzWGkKK0fO5yJiC/0KT6MFMc=" + }, + "babel-plugin-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/babel-plugin-runtime/-/babel-plugin-runtime-1.0.7.tgz", + "integrity": "sha1-v3x9lm3Vbs1cF/ocslPJrLflSq8=" + }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=" + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=" + }, + "babel-plugin-syntax-object-rest-spread": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=" + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=" + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "requires": { + "babel-helper-remap-async-to-generator": "6.24.1", + "babel-plugin-syntax-async-functions": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.5" + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "requires": { + "babel-helper-define-map": "6.26.0", + "babel-helper-function-name": "6.24.1", + "babel-helper-optimise-call-expression": "6.24.1", + "babel-helper-replace-supers": "6.24.1", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "6.26.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz", + "integrity": "sha1-DYOUApt9xqvhqX7xgeAHWN0uXYo=", + "requires": { + "babel-plugin-transform-strict-mode": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "requires": { + "babel-helper-hoist-variables": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "requires": { + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "requires": { + "babel-helper-replace-supers": "6.24.1", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "requires": { + "babel-helper-call-delegate": "6.24.1", + "babel-helper-get-function-arity": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "requires": { + "babel-helper-regex": "6.26.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "requires": { + "babel-helper-regex": "6.26.0", + "babel-runtime": "6.26.0", + "regexpu-core": "2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "6.24.1", + "babel-plugin-syntax-exponentiation-operator": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-object-rest-spread": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz", + "integrity": "sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=", + "requires": { + "babel-plugin-syntax-object-rest-spread": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "requires": { + "regenerator-transform": "0.10.1" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-undeclared-variables-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-undeclared-variables-check/-/babel-plugin-undeclared-variables-check-1.0.2.tgz", + "integrity": "sha1-XPGqU52BP/ZOmWQSkK9iCWX2Xe4=", + "requires": { + "leven": "1.0.2" + } + }, + "babel-plugin-undefined-to-void": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/babel-plugin-undefined-to-void/-/babel-plugin-undefined-to-void-1.1.6.tgz", + "integrity": "sha1-f1eO+LeN+uYAM4XYQXph7aBuL4E=" + }, + "babel-polyfill": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", + "integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=", + "requires": { + "babel-runtime": "6.26.0", + "core-js": "2.5.3", + "regenerator-runtime": "0.10.5" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=" + } + } + }, + "babel-preset-env": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.6.1.tgz", + "integrity": "sha512-W6VIyA6Ch9ePMI7VptNn2wBM6dbG0eSz25HEiL40nQXCsXGTGZSTZu1Iap+cj3Q0S5a7T9+529l/5Bkvd+afNA==", + "requires": { + "babel-plugin-check-es2015-constants": "6.22.0", + "babel-plugin-syntax-trailing-function-commas": "6.22.0", + "babel-plugin-transform-async-to-generator": "6.24.1", + "babel-plugin-transform-es2015-arrow-functions": "6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "6.22.0", + "babel-plugin-transform-es2015-block-scoping": "6.26.0", + "babel-plugin-transform-es2015-classes": "6.24.1", + "babel-plugin-transform-es2015-computed-properties": "6.24.1", + "babel-plugin-transform-es2015-destructuring": "6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "6.24.1", + "babel-plugin-transform-es2015-for-of": "6.23.0", + "babel-plugin-transform-es2015-function-name": "6.24.1", + "babel-plugin-transform-es2015-literals": "6.22.0", + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "babel-plugin-transform-es2015-modules-commonjs": "6.26.0", + "babel-plugin-transform-es2015-modules-systemjs": "6.24.1", + "babel-plugin-transform-es2015-modules-umd": "6.24.1", + "babel-plugin-transform-es2015-object-super": "6.24.1", + "babel-plugin-transform-es2015-parameters": "6.24.1", + "babel-plugin-transform-es2015-shorthand-properties": "6.24.1", + "babel-plugin-transform-es2015-spread": "6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "6.24.1", + "babel-plugin-transform-es2015-template-literals": "6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "6.24.1", + "babel-plugin-transform-exponentiation-operator": "6.24.1", + "babel-plugin-transform-regenerator": "6.26.0", + "browserslist": "2.11.3", + "invariant": "2.2.4", + "semver": "5.5.0" + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "requires": { + "babel-core": "6.26.0", + "babel-runtime": "6.26.0", + "core-js": "2.5.3", + "home-or-tmp": "2.0.0", + "lodash": "4.17.5", + "mkdirp": "0.5.1", + "source-map-support": "0.4.18" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "2.5.3", + "regenerator-runtime": "0.11.1" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "lodash": "4.17.5" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "requires": { + "babel-code-frame": "6.26.0", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "debug": "2.6.9", + "globals": "9.18.0", + "invariant": "2.2.4", + "lodash": "4.17.5" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "requires": { + "babel-runtime": "6.26.0", + "esutils": "2.0.2", + "lodash": "4.17.5", + "to-fast-properties": "1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" + }, + "backbone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.3.3.tgz", + "integrity": "sha1-TMgOp8sWMaxHSInOQPL4vGg7KZk=", + "requires": { + "underscore": "1.8.3" + } + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "requires": { + "cache-base": "1.0.1", + "class-utils": "0.3.6", + "component-emitter": "1.2.1", + "define-property": "1.0.0", + "isobject": "3.0.1", + "mixin-deep": "1.3.1", + "pascalcase": "0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "1.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" + }, + "base64-js": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.3.tgz", + "integrity": "sha512-MsAhsUW1GxCdgYSO6tAfZrNapmUKk7mWx/k5mFY/A1gBtkaCaNapTg+FExCw1r9yeaZhqx/xPg43xgTFH6KL5w==" + }, + "base64id": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-0.1.0.tgz", + "integrity": "sha1-As4P3u4M709ACA4ec+g08LG/zj8=" + }, + "basic-auth": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", + "integrity": "sha1-AV2z81PgLlY3d1X5YnQuiYHnu7o=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "requires": { + "callsite": "1.0.0" + } + }, + "binaryextensions": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-2.1.1.tgz", + "integrity": "sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA==" + }, + "blank-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/blank-object/-/blank-object-1.0.2.tgz", + "integrity": "sha1-+ZB5P76ajI3QE/syGUIL7IHV9Lk=" + }, + "blob": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz", + "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=" + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "requires": { + "inherits": "2.0.3" + } + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, + "body": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/body/-/body-5.1.0.tgz", + "integrity": "sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk=", + "requires": { + "continuable-cache": "0.3.1", + "error": "7.0.2", + "raw-body": "1.1.7", + "safe-json-parse": "1.0.1" + }, + "dependencies": { + "bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz", + "integrity": "sha1-NWnt6Lo0MV+rmcPpLLBMciDeH6g=" + }, + "raw-body": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", + "integrity": "sha1-HQJ8K/oRasxmI7yo8AAWVyqH1CU=", + "requires": { + "bytes": "1.0.0", + "string_decoder": "0.10.31" + } + } + } + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "1.0.4", + "debug": "2.6.9", + "depd": "1.1.2", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "1.6.16" + } + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "requires": { + "hoek": "2.16.3" + } + }, + "bower-config": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bower-config/-/bower-config-1.4.1.tgz", + "integrity": "sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=", + "requires": { + "graceful-fs": "4.1.11", + "mout": "1.1.0", + "optimist": "0.6.1", + "osenv": "0.1.5", + "untildify": "2.1.0" + } + }, + "bower-endpoint-parser": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/bower-endpoint-parser/-/bower-endpoint-parser-0.2.2.tgz", + "integrity": "sha1-ALVlrb+rby01rd3pd+l5Yqy8s/Y=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", + "requires": { + "expand-range": "1.8.2", + "preserve": "0.2.0", + "repeat-element": "1.1.2" + } + }, + "breakable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/breakable/-/breakable-1.0.0.tgz", + "integrity": "sha1-eEp5eRWjjq0nutRWtVcstLuqeME=" + }, + "broccoli-asset-rev": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/broccoli-asset-rev/-/broccoli-asset-rev-2.6.0.tgz", + "integrity": "sha1-BjP8OgsroMLB1W+p/rezMfyDvm0=", + "requires": { + "broccoli-asset-rewrite": "1.1.0", + "broccoli-filter": "1.3.0", + "json-stable-stringify": "1.0.1", + "minimatch": "3.0.4", + "rsvp": "3.6.2" + } + }, + "broccoli-asset-rewrite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/broccoli-asset-rewrite/-/broccoli-asset-rewrite-1.1.0.tgz", + "integrity": "sha1-d6XaVhV6oxjFkRMkXouvtGF/iDA=", + "requires": { + "broccoli-filter": "1.3.0" + } + }, + "broccoli-autoprefixer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/broccoli-autoprefixer/-/broccoli-autoprefixer-5.0.0.tgz", + "integrity": "sha1-aMnzv9//nfLTnkZUW5z51EQ9ahY=", + "requires": { + "autoprefixer": "7.2.6", + "broccoli-persistent-filter": "1.4.3", + "postcss": "6.0.19" + } + }, + "broccoli-babel-transpiler": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/broccoli-babel-transpiler/-/broccoli-babel-transpiler-6.1.4.tgz", + "integrity": "sha512-h63g7iOBWdxj0GuZw8kNsyaD1T9weKsY3I+gp3rOefozbHwUesJ43vzLy0jj3t/rbiP2czcJAlyHS48EcRil8Q==", + "requires": { + "babel-core": "6.26.0", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-persistent-filter": "1.4.3", + "clone": "2.1.1", + "hash-for-dep": "1.2.3", + "heimdalljs-logger": "0.1.9", + "json-stable-stringify": "1.0.1", + "rsvp": "3.6.2", + "workerpool": "2.3.0" + } + }, + "broccoli-builder": { + "version": "0.18.11", + "resolved": "https://registry.npmjs.org/broccoli-builder/-/broccoli-builder-0.18.11.tgz", + "integrity": "sha512-4Qa3uTev+adLRTEv2zO1M5dXSFCgywo8bCMxJ8vmas8q+dAIstc1eKnnymJgpejyuEJQAjgdhO1zxMQCrt03Ew==", + "requires": { + "heimdalljs": "0.2.5", + "promise-map-series": "0.2.3", + "quick-temp": "0.1.8", + "rimraf": "2.6.2", + "rsvp": "3.6.2", + "silent-error": "1.1.0" + } + }, + "broccoli-caching-writer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/broccoli-caching-writer/-/broccoli-caching-writer-3.0.3.tgz", + "integrity": "sha1-C9LJapc41qarWQ8HujXFFX19tHY=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.3.1", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "rimraf": "2.6.2", + "rsvp": "3.6.2", + "walk-sync": "0.3.2" + } + }, + "broccoli-clean-css": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/broccoli-clean-css/-/broccoli-clean-css-1.1.0.tgz", + "integrity": "sha1-nbFD2a9+CuecJuOsWpuy1yDqGfo=", + "requires": { + "broccoli-persistent-filter": "1.4.3", + "clean-css-promise": "0.1.1", + "inline-source-map-comment": "1.0.5", + "json-stable-stringify": "1.0.1" + } + }, + "broccoli-concat": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/broccoli-concat/-/broccoli-concat-3.2.2.tgz", + "integrity": "sha1-hv/cUmButZC6n2uJTF7HoBb1t7k=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.3.1", + "broccoli-plugin": "1.3.0", + "broccoli-stew": "1.5.0", + "ensure-posix-path": "1.0.2", + "fast-sourcemap-concat": "1.2.4", + "find-index": "1.1.0", + "fs-extra": "1.0.0", + "fs-tree-diff": "0.5.7", + "lodash.merge": "4.6.1", + "lodash.omit": "4.5.0", + "lodash.uniq": "4.5.0", + "walk-sync": "0.3.2" + }, + "dependencies": { + "fs-extra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "2.4.0", + "klaw": "1.3.1" + } + } + } + }, + "broccoli-config-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/broccoli-config-loader/-/broccoli-config-loader-1.0.1.tgz", + "integrity": "sha512-MDKYQ50rxhn+g17DYdfzfEM9DjTuSGu42Db37A8TQHQe8geYEcUZ4SQqZRgzdAI3aRQNlA1yBHJfOeGmOjhLIg==", + "requires": { + "broccoli-caching-writer": "3.0.3" + } + }, + "broccoli-config-replace": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/broccoli-config-replace/-/broccoli-config-replace-1.1.2.tgz", + "integrity": "sha1-bqh52SpbrWNNETKbUfxfSq/anAA=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.3.1", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fs-extra": "0.24.0" + }, + "dependencies": { + "fs-extra": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.24.0.tgz", + "integrity": "sha1-1OQ0KpZnXLeEZjOmCZJJMytTmVI=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "2.4.0", + "path-is-absolute": "1.0.1", + "rimraf": "2.6.2" + } + } + } + }, + "broccoli-debug": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/broccoli-debug/-/broccoli-debug-0.6.4.tgz", + "integrity": "sha512-CixMUndBqTljCc26i6ubhBrGbAWXpWBsGJFce6ZOr76Tul2Ev1xxM0tmf7OjSzdYhkr5BrPd/CNbR9VMPi+NBg==", + "requires": { + "broccoli-plugin": "1.3.0", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "symlink-or-copy": "1.2.0", + "tree-sync": "1.2.2" + } + }, + "broccoli-file-creator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/broccoli-file-creator/-/broccoli-file-creator-1.1.1.tgz", + "integrity": "sha1-GzW2fSFavfrdjUnutpSTw55sNFA=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.2.9", + "broccoli-plugin": "1.3.0", + "broccoli-writer": "0.1.1", + "mkdirp": "0.5.1", + "rsvp": "3.0.21", + "symlink-or-copy": "1.2.0" + }, + "dependencies": { + "broccoli-kitchen-sink-helpers": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.2.9.tgz", + "integrity": "sha1-peCYbtjXb7WYS2jD8EUNOpbjbsw=", + "requires": { + "glob": "5.0.15", + "mkdirp": "0.5.1" + } + }, + "rsvp": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.0.21.tgz", + "integrity": "sha1-ScWI/hjvKTvNCrn05nVuasQzNZ8=" + } + } + }, + "broccoli-filter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/broccoli-filter/-/broccoli-filter-1.3.0.tgz", + "integrity": "sha512-VXJXw7eBfG82CFxaBDjYmyN7V72D4In2zwLVQJd/h3mBfF3CMdRTsv2L20lmRTtCv1sAHcB+LgMso90e/KYiLw==", + "requires": { + "broccoli-kitchen-sink-helpers": "0.3.1", + "broccoli-plugin": "1.3.0", + "copy-dereference": "1.0.0", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "promise-map-series": "0.2.3", + "rsvp": "3.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-flatiron": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/broccoli-flatiron/-/broccoli-flatiron-0.0.0.tgz", + "integrity": "sha1-6XUEAWtW7qBIE7XYYv2hi28Rp38=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.2.9", + "broccoli-writer": "0.1.1", + "mkdirp": "0.3.5", + "rsvp": "3.0.21" + }, + "dependencies": { + "broccoli-kitchen-sink-helpers": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.2.9.tgz", + "integrity": "sha1-peCYbtjXb7WYS2jD8EUNOpbjbsw=", + "requires": { + "glob": "5.0.15", + "mkdirp": "0.5.1" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + } + } + }, + "mkdirp": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz", + "integrity": "sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=" + }, + "rsvp": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.0.21.tgz", + "integrity": "sha1-ScWI/hjvKTvNCrn05nVuasQzNZ8=" + } + } + }, + "broccoli-funnel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-1.2.0.tgz", + "integrity": "sha1-zdw6/F/xaFqAI0iP/3TOb7WlEpY=", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "exists-sync": "0.0.4", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-funnel-reducer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/broccoli-funnel-reducer/-/broccoli-funnel-reducer-1.0.0.tgz", + "integrity": "sha1-ETZbKnha7JsXlyo234fu8kxcwOo=" + }, + "broccoli-kitchen-sink-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.3.1.tgz", + "integrity": "sha1-d8fBgZS5ZkFj7E/O4nk0RJJuDAY=", + "requires": { + "glob": "5.0.15", + "mkdirp": "0.5.1" + } + }, + "broccoli-merge-trees": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-1.2.4.tgz", + "integrity": "sha1-oAFRm7UGfwZYnZGvopQkRaLQ/bU=", + "requires": { + "broccoli-plugin": "1.3.0", + "can-symlink": "1.0.0", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0" + } + }, + "broccoli-middleware": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/broccoli-middleware/-/broccoli-middleware-1.2.1.tgz", + "integrity": "sha1-oh8lX4v+WiHC8PvyQXrd2dJMlDY=", + "requires": { + "handlebars": "4.0.11", + "mime-types": "2.1.18" + } + }, + "broccoli-persistent-filter": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/broccoli-persistent-filter/-/broccoli-persistent-filter-1.4.3.tgz", + "integrity": "sha512-JwNLDvvXJlhUmr+CHcbVhCyp33NbCIAITjQZmJY9e8QzANXh3jpFWlhSFvkWghwKA8rTAKcXkW12agtiZjxr4g==", + "requires": { + "async-disk-cache": "1.3.3", + "async-promise-queue": "1.0.4", + "broccoli-plugin": "1.3.0", + "fs-tree-diff": "0.5.7", + "hash-for-dep": "1.2.3", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "mkdirp": "0.5.1", + "promise-map-series": "0.2.3", + "rimraf": "2.6.2", + "rsvp": "3.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-plugin": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/broccoli-plugin/-/broccoli-plugin-1.3.0.tgz", + "integrity": "sha1-vucEqOQtoIy1jlE6qkNu+38O8e4=", + "requires": { + "promise-map-series": "0.2.3", + "quick-temp": "0.1.8", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0" + } + }, + "broccoli-sass-source-maps": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/broccoli-sass-source-maps/-/broccoli-sass-source-maps-2.2.0.tgz", + "integrity": "sha512-X1yTOGQcjQxYebP+hjeAI286x63VZ0WfgFxqHsr4eimgNNL2TPxkJKKgOaDKJ3nE8pszbJWgHrWpEVXuwgsUzw==", + "requires": { + "broccoli-caching-writer": "3.0.3", + "include-path-searcher": "0.1.0", + "mkdirp": "0.3.5", + "node-sass": "4.7.2", + "object-assign": "2.1.1", + "rsvp": "3.6.2" + }, + "dependencies": { + "mkdirp": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz", + "integrity": "sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=" + }, + "object-assign": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz", + "integrity": "sha1-Q8NuXVaf+OSBbE76i+AtJpZ8GKo=" + } + } + }, + "broccoli-slow-trees": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/broccoli-slow-trees/-/broccoli-slow-trees-3.0.1.tgz", + "integrity": "sha1-m/Kp4vjrPtOj8qvd6YjaQ3zNybQ=", + "requires": { + "heimdalljs": "0.2.5" + } + }, + "broccoli-source": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/broccoli-source/-/broccoli-source-1.1.0.tgz", + "integrity": "sha1-VPDoLItz9GWAy7xPV48LMvyo+Ak=" + }, + "broccoli-stew": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/broccoli-stew/-/broccoli-stew-1.5.0.tgz", + "integrity": "sha1-16+MGFEdzlEOSdMIpi5Zd/RhiDw=", + "requires": { + "broccoli-debug": "0.6.4", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-persistent-filter": "1.4.3", + "broccoli-plugin": "1.3.0", + "chalk": "1.1.3", + "debug": "2.6.9", + "ensure-posix-path": "1.0.2", + "fs-extra": "2.1.2", + "minimatch": "3.0.4", + "resolve": "1.5.0", + "rsvp": "3.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + }, + "dependencies": { + "fs-extra": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", + "integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "2.4.0" + } + } + } + }, + "broccoli-style-manifest": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/broccoli-style-manifest/-/broccoli-style-manifest-1.5.0.tgz", + "integrity": "sha512-AUVnF0q2pNFjS/WJFUNOS3B53dSH1m4UThHKbOlZJnNXdxJyki5I18fY+DQzJAjpLmx1cieT9XIXC6AlJ/GLyg==", + "requires": { + "broccoli-plugin": "1.3.0", + "fs-tree-diff": "0.5.7", + "md5": "2.2.1", + "rsvp": "4.8.2", + "walk-sync": "0.3.2" + }, + "dependencies": { + "rsvp": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.2.tgz", + "integrity": "sha512-8CU1Wjxvzt6bt8zln+hCjyieneU9s0LRW+lPRsjyVCY8Vm1kTbK7btBIrCGg6yY9U4undLDm/b1hKEEi1tLypg==" + } + } + }, + "broccoli-templater": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/broccoli-templater/-/broccoli-templater-1.0.0.tgz", + "integrity": "sha1-fAVKrPWW0YaNGkQpH57HuQfTDs8=", + "requires": { + "broccoli-filter": "0.1.14", + "broccoli-stew": "1.5.0", + "lodash.template": "3.6.2" + }, + "dependencies": { + "broccoli-filter": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/broccoli-filter/-/broccoli-filter-0.1.14.tgz", + "integrity": "sha1-I8rjiR/567e019sAxtzwNTXa960=", + "requires": { + "broccoli-kitchen-sink-helpers": "0.2.9", + "broccoli-writer": "0.1.1", + "mkdirp": "0.3.5", + "promise-map-series": "0.2.3", + "quick-temp": "0.1.8", + "rsvp": "3.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.1.3" + } + }, + "broccoli-kitchen-sink-helpers": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.2.9.tgz", + "integrity": "sha1-peCYbtjXb7WYS2jD8EUNOpbjbsw=", + "requires": { + "glob": "5.0.15", + "mkdirp": "0.5.1" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + } + } + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=" + }, + "lodash.escape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz", + "integrity": "sha1-mV7g3BjBtIzJLv+ucaEKq1tIdpg=", + "requires": { + "lodash._root": "3.0.1" + } + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "requires": { + "lodash._getnative": "3.9.1", + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + }, + "lodash.template": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz", + "integrity": "sha1-+M3sxhaaJVvpCYrosMU9N4kx0U8=", + "requires": { + "lodash._basecopy": "3.0.1", + "lodash._basetostring": "3.0.1", + "lodash._basevalues": "3.0.0", + "lodash._isiterateecall": "3.0.9", + "lodash._reinterpolate": "3.0.0", + "lodash.escape": "3.2.0", + "lodash.keys": "3.1.2", + "lodash.restparam": "3.6.1", + "lodash.templatesettings": "3.1.1" + } + }, + "lodash.templatesettings": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz", + "integrity": "sha1-+zB4RHU7Zrnxr6VOJix0UwfbqOU=", + "requires": { + "lodash._reinterpolate": "3.0.0", + "lodash.escape": "3.2.0" + } + }, + "mkdirp": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz", + "integrity": "sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=" + }, + "walk-sync": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-0.1.3.tgz", + "integrity": "sha1-igcmGgC9ps+xviXp8QD61XVG9YM=" + } + } + }, + "broccoli-uglify-sourcemap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/broccoli-uglify-sourcemap/-/broccoli-uglify-sourcemap-2.0.2.tgz", + "integrity": "sha512-Fe+qUlPC4gHG7ojQOaRSRrCbrI2niYNAfrZqLkPEXHCi8UtwEqMHBAK6AZylN7ve/0CkGNSxKhCm7ELeeJRI+A==", + "requires": { + "broccoli-plugin": "1.3.0", + "debug": "3.1.0", + "lodash.defaultsdeep": "4.6.0", + "matcher-collection": "1.0.5", + "mkdirp": "0.5.1", + "source-map-url": "0.4.0", + "symlink-or-copy": "1.2.0", + "uglify-es": "3.3.9", + "walk-sync": "0.3.2" + }, + "dependencies": { + "commander": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", + "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" + }, + "uglify-es": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", + "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", + "requires": { + "commander": "2.13.0", + "source-map": "0.6.1" + } + } + } + }, + "broccoli-unwatched-tree": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/broccoli-unwatched-tree/-/broccoli-unwatched-tree-0.1.3.tgz", + "integrity": "sha1-qw+4IPYThFv2eoA7qtgg9oseOq4=", + "requires": { + "broccoli-source": "1.1.0" + } + }, + "broccoli-writer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/broccoli-writer/-/broccoli-writer-0.1.1.tgz", + "integrity": "sha1-1NcaqPKvvGejhmuRotp5CEuWqy0=", + "requires": { + "quick-temp": "0.1.8", + "rsvp": "3.6.2" + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "browser-pack": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.0.4.tgz", + "integrity": "sha512-Q4Rvn7P6ObyWfc4stqLWHtG1MJ8vVtjgT24Zbu+8UTzxYuZouqZsmNRRTFVMY/Ux0eIKv1d+JWzsInTX+fdHPQ==", + "requires": { + "JSONStream": "1.3.2", + "combine-source-map": "0.8.0", + "defined": "1.0.0", + "safe-buffer": "5.1.1", + "through2": "2.0.3", + "umd": "3.0.3" + } + }, + "browser-resolve": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.2.tgz", + "integrity": "sha1-j/CbCixCFxihBRwmCzLkj0QpOM4=", + "requires": { + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=" + } + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=" + }, + "browserify": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-13.3.0.tgz", + "integrity": "sha1-tanJAgJD8McORnW+yCI7xifkFc4=", + "requires": { + "JSONStream": "1.3.2", + "assert": "1.4.1", + "browser-pack": "6.0.4", + "browser-resolve": "1.11.2", + "browserify-zlib": "0.1.4", + "buffer": "4.9.1", + "cached-path-relative": "1.0.1", + "concat-stream": "1.5.2", + "console-browserify": "1.1.0", + "constants-browserify": "1.0.0", + "crypto-browserify": "3.12.0", + "defined": "1.0.0", + "deps-sort": "2.0.0", + "domain-browser": "1.1.7", + "duplexer2": "0.1.4", + "events": "1.1.1", + "glob": "7.1.2", + "has": "1.0.1", + "htmlescape": "1.1.1", + "https-browserify": "0.0.1", + "inherits": "2.0.3", + "insert-module-globals": "7.0.2", + "labeled-stream-splicer": "2.0.0", + "module-deps": "4.1.1", + "os-browserify": "0.1.2", + "parents": "1.0.1", + "path-browserify": "0.0.0", + "process": "0.11.10", + "punycode": "1.4.1", + "querystring-es3": "0.2.1", + "read-only-stream": "2.0.0", + "readable-stream": "2.3.5", + "resolve": "1.5.0", + "shasum": "1.0.2", + "shell-quote": "1.6.1", + "stream-browserify": "2.0.1", + "stream-http": "2.8.1", + "string_decoder": "0.10.31", + "subarg": "1.0.0", + "syntax-error": "1.4.0", + "through2": "2.0.3", + "timers-browserify": "1.4.2", + "tty-browserify": "0.0.1", + "url": "0.11.0", + "util": "0.10.3", + "vm-browserify": "0.0.4", + "xtend": "4.0.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "browserify-aes": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.1.1.tgz", + "integrity": "sha512-UGnTYAnB2a3YuYKIRy1/4FB2HdM866E0qC46JXvVTYKlBlZlnvfpSfY6OKfXZAkv70eJ2a1SqzpAo5CRhZGDFg==", + "requires": { + "buffer-xor": "1.0.3", + "cipher-base": "1.0.4", + "create-hash": "1.1.3", + "evp_bytestokey": "1.0.3", + "inherits": "2.0.3", + "safe-buffer": "5.1.1" + } + }, + "browserify-cipher": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.0.tgz", + "integrity": "sha1-mYgkSHS/XtTijalWZtzWasj8Njo=", + "requires": { + "browserify-aes": "1.1.1", + "browserify-des": "1.0.0", + "evp_bytestokey": "1.0.3" + } + }, + "browserify-des": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.0.tgz", + "integrity": "sha1-2qJ3cXRwki7S/hhZQRihdUOXId0=", + "requires": { + "cipher-base": "1.0.4", + "des.js": "1.0.0", + "inherits": "2.0.3" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "requires": { + "bn.js": "4.11.8", + "randombytes": "2.0.6" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "requires": { + "bn.js": "4.11.8", + "browserify-rsa": "4.0.1", + "create-hash": "1.1.3", + "create-hmac": "1.1.6", + "elliptic": "6.4.0", + "inherits": "2.0.3", + "parse-asn1": "5.1.0" + } + }, + "browserify-zlib": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", + "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=", + "requires": { + "pako": "0.2.9" + } + }, + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "requires": { + "caniuse-lite": "1.0.30000815", + "electron-to-chromium": "1.3.37" + } + }, + "bser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.0.0.tgz", + "integrity": "sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk=", + "requires": { + "node-int64": "0.4.0" + } + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "1.2.3", + "ieee754": "1.1.8", + "isarray": "1.0.0" + } + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=" + }, + "build": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/build/-/build-0.1.4.tgz", + "integrity": "sha1-cH/gJv/O3crL/c3zVur9pk8VEEY=", + "requires": { + "cssmin": "0.3.2", + "jsmin": "1.0.1", + "jxLoader": "0.1.1", + "moo-server": "1.3.0", + "promised-io": "0.3.5", + "timespan": "2.3.0", + "uglify-js": "1.3.5", + "walker": "1.0.7", + "winston": "2.4.1", + "wrench": "1.3.9" + }, + "dependencies": { + "uglify-js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.3.5.tgz", + "integrity": "sha1-S1v/+Rhu/7qoiOTJ6UvZ/EyUkp0=" + } + } + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" + }, + "builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha1-y5T662HIaWRR2zZTThQi+U8K7og=" + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "requires": { + "collection-visit": "1.0.0", + "component-emitter": "1.2.1", + "get-value": "2.0.6", + "has-value": "1.0.0", + "isobject": "3.0.1", + "set-value": "2.0.0", + "to-object-path": "0.3.0", + "union-value": "1.0.0", + "unset-value": "1.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "cached-path-relative": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.1.tgz", + "integrity": "sha1-0JxLUoAKpMB44t2BqGmqyQ0uVOc=" + }, + "calculate-cache-key-for-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-1.1.0.tgz", + "integrity": "sha1-DD5CycE088neU1jA8WeTYn6pdtY=", + "requires": { + "json-stable-stringify": "1.0.1" + } + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "0.2.0" + } + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=" + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "requires": { + "camelcase": "2.1.1", + "map-obj": "1.0.1" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" + } + } + }, + "can-symlink": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/can-symlink/-/can-symlink-1.0.0.tgz", + "integrity": "sha1-l7YH2KhLtsbiKLkC2GTstZS50hk=", + "requires": { + "tmp": "0.0.28" + } + }, + "caniuse-lite": { + "version": "1.0.30000815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000815.tgz", + "integrity": "sha512-PGSOPK6gFe5fWd+eD0u2bG0aOsN1qC4B1E66tl3jOsIoKkTIcBYAc2+O6AeNzKW8RsFykWgnhkTlfOyuTzgI9A==" + }, + "capture-exit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-1.2.0.tgz", + "integrity": "sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28=", + "requires": { + "rsvp": "3.6.2" + } + }, + "cardinal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-1.0.0.tgz", + "integrity": "sha1-UOIcGwqjdyn5N33vGWtanOyTLuk=", + "requires": { + "ansicolors": "0.2.1", + "redeyed": "1.0.1" + } + }, + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=" + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "requires": { + "align-text": "0.1.4", + "lazy-cache": "1.0.4" + } + }, + "chai": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", + "requires": { + "assertion-error": "1.1.0", + "deep-eql": "0.1.3", + "type-detect": "1.0.0" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, + "charm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/charm/-/charm-1.0.2.tgz", + "integrity": "sha1-it02cVOm2aWBMxBSxAkJkdqZXjU=", + "requires": { + "inherits": "2.0.3" + } + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "requires": { + "inherits": "2.0.3", + "safe-buffer": "5.1.1" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "clap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/clap/-/clap-1.2.3.tgz", + "integrity": "sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==", + "requires": { + "chalk": "1.1.3" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "requires": { + "arr-union": "3.1.0", + "define-property": "0.2.5", + "isobject": "3.0.1", + "static-extend": "0.1.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + }, + "clean-base-url": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-base-url/-/clean-base-url-1.0.0.tgz", + "integrity": "sha1-yQHPCiC5ckNbDszVLQVoJKQ1G3s=" + }, + "clean-css": { + "version": "3.4.28", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-3.4.28.tgz", + "integrity": "sha1-vxlF6C/ICPVWlebd6uwBQA79A/8=", + "requires": { + "commander": "2.8.1", + "source-map": "0.4.4" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "clean-css-promise": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/clean-css-promise/-/clean-css-promise-0.1.1.tgz", + "integrity": "sha1-Q/PSyN/LK/BxSBJSzZt2QzwI7ss=", + "requires": { + "array-to-error": "1.1.1", + "clean-css": "3.4.28", + "pinkie-promise": "2.0.1" + } + }, + "clean-up-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-up-path/-/clean-up-path-1.0.0.tgz", + "integrity": "sha512-PHGlEF0Z6976qQyN6gM7kKH6EH0RdfZcc8V+QhFe36eRxV0SMH5OUBZG7Bxa9YcreNzyNbK63cGiZxdSZgosRw==" + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "requires": { + "restore-cursor": "1.0.1" + } + }, + "cli-spinners": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.1.0.tgz", + "integrity": "sha1-8YR7FohE2RemceudFH499JfJDQY=" + }, + "cli-table": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.1.tgz", + "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", + "requires": { + "colors": "1.0.3" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=" + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "requires": { + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wrap-ansi": "2.1.0" + } + }, + "clone": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.1.tgz", + "integrity": "sha1-0hfR6WERjjrJpLi7oyhVU79kfNs=" + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "coa": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.4.tgz", + "integrity": "sha1-qe8VNmDWqGqL3sAomlxoTSF0Mv0=", + "requires": { + "q": "1.5.1" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "requires": { + "map-visit": "1.0.0", + "object-visit": "1.0.1" + } + }, + "color-convert": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + }, + "combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", + "requires": { + "convert-source-map": "1.1.3", + "inline-source-map": "0.6.2", + "lodash.memoize": "3.0.4", + "source-map": "0.5.7" + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "1.0.0" + } + }, + "commander": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", + "requires": { + "graceful-readlink": "1.0.1" + } + }, + "common-tags": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.7.2.tgz", + "integrity": "sha512-joj9ZlUOjCrwdbmiLqafeUSgkUM74NqhLsZtSqDmhKudaIY197zTrb8JMl31fMnCUuxwFT23eC/oWvrZzDLRJQ==", + "requires": { + "babel-runtime": "6.26.0" + } + }, + "commoner": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/commoner/-/commoner-0.10.8.tgz", + "integrity": "sha1-NPw2cs0kOT6LtH5wyqApOBH08sU=", + "requires": { + "commander": "2.8.1", + "detective": "4.7.1", + "glob": "5.0.15", + "graceful-fs": "4.1.11", + "iconv-lite": "0.4.19", + "mkdirp": "0.5.1", + "private": "0.1.8", + "q": "1.5.1", + "recast": "0.11.23" + }, + "dependencies": { + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + }, + "recast": { + "version": "0.11.23", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", + "integrity": "sha1-RR/TAEqx5N+bTktmN2sqIZEkYtM=", + "requires": { + "ast-types": "0.9.6", + "esprima": "3.1.3", + "private": "0.1.8", + "source-map": "0.5.7" + } + } + } + }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" + }, + "compressible": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.13.tgz", + "integrity": "sha1-DRAgq5JLL9tNYnmHXH1tq6a6p6k=", + "requires": { + "mime-db": "1.33.0" + } + }, + "compression": { + "version": "1.7.2", + "resolved": "http://registry.npmjs.org/compression/-/compression-1.7.2.tgz", + "integrity": "sha1-qv+81qr4VLROuygDU9WtFlH1mmk=", + "requires": { + "accepts": "1.3.5", + "bytes": "3.0.0", + "compressible": "2.0.13", + "debug": "2.6.9", + "on-headers": "1.0.1", + "safe-buffer": "5.1.1", + "vary": "1.1.2" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.2.tgz", + "integrity": "sha1-cIl4Yk2FavQaWnQd790mHadSwmY=", + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.0.6", + "typedarray": "0.0.6" + }, + "dependencies": { + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "0.10.31", + "util-deprecate": "1.0.2" + } + } + } + }, + "configstore": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.1.tgz", + "integrity": "sha512-5oNkD/L++l0O6xGXxb1EWS7SivtjfGQlRyxJsYgE0Z495/L81e2h4/d3r969hoPXuFItzNOKMtsXgYG4c7dYvw==", + "requires": { + "dot-prop": "4.2.0", + "graceful-fs": "4.1.11", + "make-dir": "1.2.0", + "unique-string": "1.0.0", + "write-file-atomic": "2.3.0", + "xdg-basedir": "3.0.0" + } + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "requires": { + "date-now": "0.1.4" + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, + "console-ui": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/console-ui/-/console-ui-2.2.2.tgz", + "integrity": "sha1-spSik03oad0GeJq0vmlVVBHt7yk=", + "requires": { + "chalk": "2.3.2", + "inquirer": "2.0.0", + "json-stable-stringify": "1.0.1", + "ora": "2.0.0", + "through": "2.3.8", + "user-info": "1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "consolidate": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.14.5.tgz", + "integrity": "sha1-WiUEe8dvcwcmZ8jLUsmJiI9JTGM=", + "requires": { + "bluebird": "3.5.1" + } + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=" + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "continuable-cache": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", + "integrity": "sha1-vXJ6f67XfnH/OYWskzUakSczrQ8=" + }, + "convert-source-map": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "copy-dereference": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/copy-dereference/-/copy-dereference-1.0.0.tgz", + "integrity": "sha1-axMYZUIP2BtBO6mUtE02VTERUrY=" + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" + }, + "core-js": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.3.tgz", + "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=" + }, + "core-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/core-object/-/core-object-1.1.0.tgz", + "integrity": "sha1-htY5GHM8+doaWq5ynmLAqI5mrQo=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "create-ecdh": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.0.tgz", + "integrity": "sha1-iIxyNZbN92EvZJgjPuvXo1MBc30=", + "requires": { + "bn.js": "4.11.8", + "elliptic": "6.4.0" + } + }, + "create-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", + "integrity": "sha1-YGBCrIuSYnUPSDyt2rD1gZFy2P0=", + "requires": { + "cipher-base": "1.0.4", + "inherits": "2.0.3", + "ripemd160": "2.0.1", + "sha.js": "2.4.10" + } + }, + "create-hmac": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.6.tgz", + "integrity": "sha1-rLniIaThe9sHbpBlfEK5PjcmzwY=", + "requires": { + "cipher-base": "1.0.4", + "create-hash": "1.1.3", + "inherits": "2.0.3", + "ripemd160": "2.0.1", + "safe-buffer": "5.1.1", + "sha.js": "2.4.10" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "requires": { + "lru-cache": "4.1.2", + "shebang-command": "1.2.0", + "which": "1.3.0" + } + }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, + "cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", + "requires": { + "boom": "2.10.1" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "requires": { + "browserify-cipher": "1.0.0", + "browserify-sign": "4.0.4", + "create-ecdh": "4.0.0", + "create-hash": "1.1.3", + "create-hmac": "1.1.6", + "diffie-hellman": "5.0.2", + "inherits": "2.0.3", + "pbkdf2": "3.0.14", + "public-encrypt": "4.0.0", + "randombytes": "2.0.6", + "randomfill": "1.0.4" + } + }, + "crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=" + }, + "cssmin": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/cssmin/-/cssmin-0.3.2.tgz", + "integrity": "sha1-3c5MVHtRCuDVlKjx+/iq+OLFwA0=" + }, + "csso": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-2.0.0.tgz", + "integrity": "sha1-F4tDpEYhIhwndWCG9THgL0KQDug=", + "requires": { + "clap": "1.2.3", + "source-map": "0.5.7" + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "requires": { + "array-find-index": "1.0.2" + } + }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "requires": { + "es5-ext": "0.10.40" + } + }, + "dag-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-2.0.2.tgz", + "integrity": "sha1-lxS0ct6CoYQ94vuptodpOMq0TGg=" + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "date-fns": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz", + "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw==" + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" + }, + "deep-eql": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", + "requires": { + "type-detect": "0.1.1" + }, + "dependencies": { + "type-detect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=" + } + } + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "1.0.3" + }, + "dependencies": { + "clone": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.3.tgz", + "integrity": "sha1-KY1+IjFmD0DAA8LtMUDezz9TCF8=" + } + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "requires": { + "is-descriptor": "1.0.2", + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" + }, + "defs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/defs/-/defs-1.1.1.tgz", + "integrity": "sha1-siYJ8sehG6ej2xFoBcE5scr/qdI=", + "requires": { + "alter": "0.2.0", + "ast-traverse": "0.1.1", + "breakable": "1.0.0", + "esprima-fb": "15001.1001.0-dev-harmony-fb", + "simple-fmt": "0.1.0", + "simple-is": "0.2.0", + "stringmap": "0.2.2", + "stringset": "0.2.1", + "tryor": "0.1.2", + "yargs": "3.27.0" + }, + "dependencies": { + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "requires": { + "center-align": "0.1.3", + "right-align": "0.1.3", + "wordwrap": "0.0.2" + } + }, + "esprima-fb": { + "version": "15001.1001.0-dev-harmony-fb", + "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz", + "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk=" + }, + "window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=" + }, + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=" + }, + "yargs": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.27.0.tgz", + "integrity": "sha1-ISBUaTFuk5Ex1Z8toMbX+YIh6kA=", + "requires": { + "camelcase": "1.2.1", + "cliui": "2.1.0", + "decamelize": "1.2.0", + "os-locale": "1.4.0", + "window-size": "0.1.4", + "y18n": "3.2.1" + } + } + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "5.0.0", + "is-path-cwd": "1.0.0", + "is-path-in-cwd": "1.0.0", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "rimraf": "2.6.2" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "deps-sort": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.0.tgz", + "integrity": "sha1-CRckkC6EZYJg65EHSMzNGvbiH7U=", + "requires": { + "JSONStream": "1.3.2", + "shasum": "1.0.2", + "subarg": "1.0.0", + "through2": "2.0.3" + } + }, + "derequire": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/derequire/-/derequire-2.0.6.tgz", + "integrity": "sha1-MaQUu3yhdiOfp4sRZjbvd9UX52g=", + "requires": { + "acorn": "4.0.13", + "concat-stream": "1.5.2", + "escope": "3.6.0", + "through2": "2.0.3", + "yargs": "6.6.0" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + } + } + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "requires": { + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "requires": { + "repeating": "2.0.1" + } + }, + "detective": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-4.7.1.tgz", + "integrity": "sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig==", + "requires": { + "acorn": "5.5.3", + "defined": "1.0.0" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" + }, + "diffie-hellman": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.2.tgz", + "integrity": "sha1-tYNXOScM/ias9jIJn97SoH8gnl4=", + "requires": { + "bn.js": "4.11.8", + "miller-rabin": "4.0.1", + "randombytes": "2.0.6" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "2.0.2" + } + }, + "domain-browser": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz", + "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=" + }, + "dot-prop": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", + "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", + "requires": { + "is-obj": "1.0.1" + } + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "requires": { + "readable-stream": "2.3.5" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "editions": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz", + "integrity": "sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "electron-to-chromium": { + "version": "1.3.37", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.37.tgz", + "integrity": "sha1-SpJzTgBEyM8LFVO+V+riGkxuX6s=" + }, + "elliptic": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.0.tgz", + "integrity": "sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8=", + "requires": { + "bn.js": "4.11.8", + "brorand": "1.1.0", + "hash.js": "1.1.3", + "hmac-drbg": "1.0.1", + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0", + "minimalistic-crypto-utils": "1.0.1" + } + }, + "ember-basic-dropdown": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/ember-basic-dropdown/-/ember-basic-dropdown-1.0.0-beta.3.tgz", + "integrity": "sha512-1a1VWJ0+yrjt9iSksyd9mxsxAA7pCi2+TfST4UsDuGwRXwLtmmWvm+AWSdPu6PkOTVxeqMMKqim4HbXMmu9L9Q==", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "2.0.3", + "ember-maybe-in-element": "0.1.3", + "ember-native-dom-helpers": "0.5.10" + } + }, + "ember-browserify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ember-browserify/-/ember-browserify-1.2.1.tgz", + "integrity": "sha512-PcUWx2NYNTdWqHuZxP84NXbfO0s4u3j5xlxR36APviuwoOVAAHkLNB5e74WJzhQC6dzG6UhOnK93LJ/xKJ5VBQ==", + "requires": { + "acorn": "5.5.3", + "broccoli-caching-writer": "3.0.3", + "broccoli-kitchen-sink-helpers": "0.3.1", + "broccoli-merge-trees": "1.2.4", + "broccoli-plugin": "1.3.0", + "browserify": "13.3.0", + "core-object": "1.1.0", + "debug": "2.6.9", + "derequire": "2.0.6", + "ember-cli-version-checker": "2.1.0", + "fs-tree": "1.0.0", + "fs-tree-diff": "0.5.7", + "lodash": "4.17.5", + "md5-hex": "1.3.0", + "mkdirp": "0.5.1", + "promise-map-series": "0.2.3", + "quick-temp": "0.1.8", + "rimraf": "2.6.2", + "rsvp": "3.6.2", + "symlink-or-copy": "1.2.0", + "through2": "2.0.3", + "walk-sync": "0.2.7" + }, + "dependencies": { + "walk-sync": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-0.2.7.tgz", + "integrity": "sha1-tJvk7mhnZXrrc2l4tWop0Q+jmWk=", + "requires": { + "ensure-posix-path": "1.0.2", + "matcher-collection": "1.0.5" + } + } + } + }, + "ember-checkbox-with-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ember-checkbox-with-label/-/ember-checkbox-with-label-1.1.0.tgz", + "integrity": "sha1-XyLxnc2XPRMChMecWo3njHZPmtE=", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "2.0.3" + } + }, + "ember-cli": { + "version": "3.1.0-beta.1", + "resolved": "https://registry.npmjs.org/ember-cli/-/ember-cli-3.1.0-beta.1.tgz", + "integrity": "sha1-oO3C/LDWjj0JEoIAzwjHb7anbr8=", + "requires": { + "amd-name-resolver": "1.2.0", + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "bower-config": "1.4.1", + "bower-endpoint-parser": "0.2.2", + "broccoli-babel-transpiler": "6.1.4", + "broccoli-builder": "0.18.11", + "broccoli-concat": "3.2.2", + "broccoli-config-loader": "1.0.1", + "broccoli-config-replace": "1.1.2", + "broccoli-debug": "0.6.4", + "broccoli-funnel": "2.0.1", + "broccoli-funnel-reducer": "1.0.0", + "broccoli-merge-trees": "2.0.0", + "broccoli-middleware": "1.2.1", + "broccoli-source": "1.1.0", + "broccoli-stew": "1.5.0", + "calculate-cache-key-for-tree": "1.1.0", + "capture-exit": "1.2.0", + "chalk": "2.3.2", + "clean-base-url": "1.0.0", + "compression": "1.7.2", + "configstore": "3.1.1", + "console-ui": "2.2.2", + "core-object": "3.1.5", + "dag-map": "2.0.2", + "diff": "3.5.0", + "ember-cli-broccoli-sane-watcher": "2.1.1", + "ember-cli-is-package-missing": "1.0.0", + "ember-cli-lodash-subset": "2.0.1", + "ember-cli-normalize-entity-name": "1.0.0", + "ember-cli-preprocess-registry": "3.1.1", + "ember-cli-string-utils": "1.1.0", + "ensure-posix-path": "1.0.2", + "execa": "0.9.0", + "exists-sync": "0.0.4", + "exit": "0.1.2", + "express": "4.16.3", + "filesize": "3.6.0", + "find-up": "2.1.0", + "find-yarn-workspace-root": "1.0.0", + "fs-extra": "5.0.0", + "fs-tree-diff": "0.5.7", + "get-caller-file": "1.0.2", + "git-repo-info": "1.4.1", + "glob": "7.1.2", + "heimdalljs": "0.2.5", + "heimdalljs-fs-monitor": "0.2.0", + "heimdalljs-graph": "0.3.4", + "heimdalljs-logger": "0.1.9", + "http-proxy": "1.16.2", + "inflection": "1.12.0", + "is-git-url": "1.0.0", + "isbinaryfile": "3.0.2", + "js-yaml": "3.11.0", + "json-stable-stringify": "1.0.1", + "leek": "0.0.24", + "lodash.template": "4.4.0", + "markdown-it": "8.4.1", + "markdown-it-terminal": "0.1.0", + "minimatch": "3.0.4", + "morgan": "1.9.0", + "node-modules-path": "1.0.1", + "nopt": "3.0.6", + "npm-package-arg": "6.0.0", + "portfinder": "1.0.13", + "promise-map-series": "0.2.3", + "quick-temp": "0.1.8", + "resolve": "1.5.0", + "rsvp": "4.8.2", + "sane": "2.4.1", + "semver": "5.5.0", + "silent-error": "1.1.0", + "sort-package-json": "1.11.0", + "symlink-or-copy": "1.2.0", + "temp": "0.8.3", + "testem": "2.0.0", + "tiny-lr": "1.1.1", + "tree-sync": "1.2.2", + "uuid": "3.2.1", + "validate-npm-package-name": "3.0.0", + "walk-sync": "0.3.2", + "watch-detector": "0.1.0", + "yam": "0.0.24" + }, + "dependencies": { + "amd-name-resolver": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/amd-name-resolver/-/amd-name-resolver-1.2.0.tgz", + "integrity": "sha512-hlSTWGS1t6/xq5YCed7YALg7tKZL3rkl7UwEZ/eCIkn8JxmM6fU6Qs/1hwtjQqfuYxlffuUcgYEm0f5xP4YKaA==", + "requires": { + "ensure-posix-path": "1.0.2" + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "core-object": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/core-object/-/core-object-3.1.5.tgz", + "integrity": "sha512-sA2/4+/PZ/KV6CKgjrVrrUVBKCkdDO02CUlQ0YKTQoYUwPYNOtOAcWlbYhd5v/1JqYaA6oZ4sDlOU4ppVw6Wbg==", + "requires": { + "chalk": "2.3.2" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "requires": { + "locate-path": "2.0.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "rsvp": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.2.tgz", + "integrity": "sha512-8CU1Wjxvzt6bt8zln+hCjyieneU9s0LRW+lPRsjyVCY8Vm1kTbK7btBIrCGg6yY9U4undLDm/b1hKEEi1tLypg==" + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "ember-cli-autoprefixer": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/ember-cli-autoprefixer/-/ember-cli-autoprefixer-0.8.1.tgz", + "integrity": "sha1-Bx3ZV0RRBXsD3MA7cfW9nLB+8zI=", + "requires": { + "broccoli-autoprefixer": "5.0.0", + "lodash": "4.17.5" + } + }, + "ember-cli-babel": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ember-cli-babel/-/ember-cli-babel-6.12.0.tgz", + "integrity": "sha512-LMwZ3Xf3Q3jQUXaJtLLJsbbhRZRNv/iea64lZ8OgqZp1fh66CSXfmqV3L9QSuYQKPDNqFiu2v6IpOT08C6GU6w==", + "requires": { + "amd-name-resolver": "0.0.7", + "babel-plugin-debug-macros": "0.1.11", + "babel-plugin-ember-modules-api-polyfill": "2.3.0", + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "babel-polyfill": "6.26.0", + "babel-preset-env": "1.6.1", + "broccoli-babel-transpiler": "6.1.4", + "broccoli-debug": "0.6.4", + "broccoli-funnel": "1.2.0", + "broccoli-source": "1.1.0", + "clone": "2.1.1", + "ember-cli-version-checker": "2.1.0", + "semver": "5.5.0" + } + }, + "ember-cli-broccoli-sane-watcher": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ember-cli-broccoli-sane-watcher/-/ember-cli-broccoli-sane-watcher-2.1.1.tgz", + "integrity": "sha512-fG2AbvtNVXoV05wf2svN8SoEnpZrMbxL6t7g+a1FSySfe0lkTvF94s8Zwa5fJKyQV8/HyKD8iWQcJGOp3DxPKA==", + "requires": { + "broccoli-slow-trees": "3.0.1", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "rsvp": "3.6.2", + "sane": "2.4.1" + } + }, + "ember-cli-content-security-policy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-content-security-policy/-/ember-cli-content-security-policy-1.0.0.tgz", + "integrity": "sha512-5JSm22epRFkMH5h+/HgfnspjYPIXNP4RXUUSS8mj1KV3ZJZzcY3835YNnYYi3Tx5SLLHFqadT851zCXfcQei9w==", + "requires": { + "body-parser": "1.18.2", + "chalk": "2.3.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "ember-cli-dependency-checker": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ember-cli-dependency-checker/-/ember-cli-dependency-checker-2.1.0.tgz", + "integrity": "sha1-nWYoanx3jpRzPq8hMg0SnE/Q3WQ=", + "requires": { + "chalk": "1.1.3", + "is-git-url": "1.0.0", + "resolve": "1.5.0", + "semver": "5.5.0" + } + }, + "ember-cli-flash": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ember-cli-flash/-/ember-cli-flash-1.6.3.tgz", + "integrity": "sha512-d3fQWSQgRHk8d4cgSqDXZxFf+5tbgp7JCWoVEFaNc30m3NiWUpDVmnVyejKyXaxxVoC8oeu0zR7G1CeUFlGdhw==", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "2.0.3", + "ember-runtime-enumerable-includes-polyfill": "2.1.0" + } + }, + "ember-cli-get-component-path-option": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-get-component-path-option/-/ember-cli-get-component-path-option-1.0.0.tgz", + "integrity": "sha1-DXtZVVni+QUKvtgE8djv8bCLx3E=" + }, + "ember-cli-graphql-file": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/ember-cli-graphql-file/-/ember-cli-graphql-file-0.0.1.tgz", + "integrity": "sha1-fsB7/o5Lqrmjodbm8jBh0tDE03k=", + "requires": { + "ember-cli-babel": "5.2.2", + "graphql-tag": "1.2.4" + }, + "dependencies": { + "babel-core": { + "version": "5.8.38", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-5.8.38.tgz", + "integrity": "sha1-H8ruedfmG3ULALjlT238nQr4ZVg=", + "requires": { + "babel-plugin-constant-folding": "1.0.1", + "babel-plugin-dead-code-elimination": "1.0.2", + "babel-plugin-eval": "1.0.1", + "babel-plugin-inline-environment-variables": "1.0.1", + "babel-plugin-jscript": "1.0.4", + "babel-plugin-member-expression-literals": "1.0.1", + "babel-plugin-property-literals": "1.0.1", + "babel-plugin-proto-to-assign": "1.0.4", + "babel-plugin-react-constant-elements": "1.0.3", + "babel-plugin-react-display-name": "1.0.3", + "babel-plugin-remove-console": "1.0.1", + "babel-plugin-remove-debugger": "1.0.1", + "babel-plugin-runtime": "1.0.7", + "babel-plugin-undeclared-variables-check": "1.0.2", + "babel-plugin-undefined-to-void": "1.1.6", + "babylon": "5.8.38", + "bluebird": "2.11.0", + "chalk": "1.1.3", + "convert-source-map": "1.1.3", + "core-js": "1.2.7", + "debug": "2.6.9", + "detect-indent": "3.0.1", + "esutils": "2.0.2", + "fs-readdir-recursive": "0.1.2", + "globals": "6.4.1", + "home-or-tmp": "1.0.0", + "is-integer": "1.0.7", + "js-tokens": "1.0.1", + "json5": "0.4.0", + "lodash": "3.10.1", + "minimatch": "2.0.10", + "output-file-sync": "1.1.2", + "path-exists": "1.0.0", + "path-is-absolute": "1.0.1", + "private": "0.1.8", + "regenerator": "0.8.40", + "regexpu": "1.3.0", + "repeating": "1.1.3", + "resolve": "1.5.0", + "shebang-regex": "1.0.0", + "slash": "1.0.0", + "source-map": "0.5.7", + "source-map-support": "0.2.10", + "to-fast-properties": "1.0.3", + "trim-right": "1.0.1", + "try-resolve": "1.0.1" + } + }, + "babylon": { + "version": "5.8.38", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-5.8.38.tgz", + "integrity": "sha1-7JsSCxG/bM1Bc6GL8hfmC3mFn/0=" + }, + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + }, + "broccoli-babel-transpiler": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/broccoli-babel-transpiler/-/broccoli-babel-transpiler-5.7.4.tgz", + "integrity": "sha512-gI14Pqc4qbmn5RW4SuAmybLiOoYW59D+HzQyhY6WdaGMAjikKBwJN0p17phyvafQ+kvG0mUiMd83lgHLeATnEA==", + "requires": { + "babel-core": "5.8.38", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-persistent-filter": "1.4.3", + "clone": "0.2.0", + "hash-for-dep": "1.2.3", + "heimdalljs-logger": "0.1.9", + "json-stable-stringify": "1.0.1", + "rsvp": "3.6.2", + "workerpool": "2.3.0" + }, + "dependencies": { + "clone": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz", + "integrity": "sha1-xhJqkK1Pctv1rNskPMN3JP6T/B8=" + } + } + }, + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "detect-indent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-3.0.1.tgz", + "integrity": "sha1-ncXl3bzu+DJXZLlFGwK8bVQIT3U=", + "requires": { + "get-stdin": "4.0.1", + "minimist": "1.2.0", + "repeating": "1.1.3" + } + }, + "ember-cli-babel": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ember-cli-babel/-/ember-cli-babel-5.2.2.tgz", + "integrity": "sha1-zHLYucV98bEaT2+E0JnHg4hT2Ds=", + "requires": { + "broccoli-babel-transpiler": "5.7.4", + "broccoli-funnel": "1.2.0", + "clone": "2.1.1", + "ember-cli-version-checker": "1.3.1", + "resolve": "1.5.0" + } + }, + "ember-cli-version-checker": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ember-cli-version-checker/-/ember-cli-version-checker-1.3.1.tgz", + "integrity": "sha1-C8LRNMgwFC2mS/lieg7e0QthrnI=", + "requires": { + "semver": "5.5.0" + } + }, + "globals": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/globals/-/globals-6.4.1.tgz", + "integrity": "sha1-hJgDKzttHMge68X3lpDY/in6v08=" + }, + "graphql-tag": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-1.2.4.tgz", + "integrity": "sha1-kMWb6kE3hRP9chPcklN/zSDkVw8=" + }, + "home-or-tmp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-1.0.0.tgz", + "integrity": "sha1-S58eQIAMPlDGwn94FnavzOcfOYU=", + "requires": { + "os-tmpdir": "1.0.2", + "user-home": "1.1.1" + } + }, + "js-tokens": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.1.tgz", + "integrity": "sha1-zENaXIuUrRWst5gxQPyAGCyJrq4=" + }, + "json5": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz", + "integrity": "sha1-BUNS5MTIDIbAkjh31EneF2pzLI0=" + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + }, + "minimatch": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", + "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", + "requires": { + "brace-expansion": "1.1.11" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "path-exists": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-1.0.0.tgz", + "integrity": "sha1-1aiZjrce83p0w06w2eum6HjuoIE=" + }, + "repeating": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz", + "integrity": "sha1-PUEUIYh3U3SU+X93+Xhfq4EPpKw=", + "requires": { + "is-finite": "1.0.2" + } + }, + "source-map-support": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.2.10.tgz", + "integrity": "sha1-6lo5AKHByyUJagrozFwrSxDe09w=", + "requires": { + "source-map": "0.1.32" + }, + "dependencies": { + "source-map": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz", + "integrity": "sha1-yLbBZ3l7pHQKjqMyUhYv8IWRsmY=", + "requires": { + "amdefine": "1.0.1" + } + } + } + } + } + }, + "ember-cli-htmlbars": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ember-cli-htmlbars/-/ember-cli-htmlbars-2.0.3.tgz", + "integrity": "sha512-oyWtJebOwxAqWZwMc0NKFJ8FJdxVixM7zl0FaXq1vTAG6bOgnU7yAhXEASlaO5f+PptZueZfOpdpvRwZW/Gk1A==", + "requires": { + "broccoli-persistent-filter": "1.4.3", + "hash-for-dep": "1.2.3", + "json-stable-stringify": "1.0.1", + "strip-bom": "3.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=" + } + } + }, + "ember-cli-htmlbars-inline-precompile": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ember-cli-htmlbars-inline-precompile/-/ember-cli-htmlbars-inline-precompile-1.0.2.tgz", + "integrity": "sha1-W1RPZk1dmRHwjNl5xfcNjLDKKt0=", + "requires": { + "babel-plugin-htmlbars-inline-precompile": "0.2.3", + "ember-cli-version-checker": "2.1.0", + "hash-for-dep": "1.2.3", + "heimdalljs-logger": "0.1.9", + "silent-error": "1.1.0" + } + }, + "ember-cli-inject-live-reload": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ember-cli-inject-live-reload/-/ember-cli-inject-live-reload-1.7.0.tgz", + "integrity": "sha512-+0zOwJlf4iR5NcvyeU7E7xU1qDfniP/+mXfNTfAEhHO2eE9sjQvasKV84O1sIIyLk2LMIjFPbGt7uv5fQcIGwg==" + }, + "ember-cli-is-package-missing": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-is-package-missing/-/ember-cli-is-package-missing-1.0.0.tgz", + "integrity": "sha1-bmGEyvuSY13ZPKbJRrEEKS1OM5A=" + }, + "ember-cli-lodash-subset": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ember-cli-lodash-subset/-/ember-cli-lodash-subset-2.0.1.tgz", + "integrity": "sha1-IMtop5D+D94kiN39jvu332/nZvI=" + }, + "ember-cli-mocha": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/ember-cli-mocha/-/ember-cli-mocha-0.15.0.tgz", + "integrity": "sha1-SDslosYxsrGRM0Rob0l6gc7WabY=", + "requires": { + "ember-mocha": "0.13.1" + } + }, + "ember-cli-node-assets": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ember-cli-node-assets/-/ember-cli-node-assets-0.1.6.tgz", + "integrity": "sha1-ZIiilJBIyAGtbZ4zdTx7zjL8EUY=", + "requires": { + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-unwatched-tree": "0.1.3", + "debug": "2.6.9", + "lodash": "4.17.5", + "resolve": "1.5.0" + } + }, + "ember-cli-normalize-entity-name": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-normalize-entity-name/-/ember-cli-normalize-entity-name-1.0.0.tgz", + "integrity": "sha1-CxT3vLxZmqEXtf3cgeT9A8S61bc=", + "requires": { + "silent-error": "1.1.0" + } + }, + "ember-cli-path-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-path-utils/-/ember-cli-path-utils-1.0.0.tgz", + "integrity": "sha1-Tjmvi1UwHN3FAXc5t3qAT7ogce0=" + }, + "ember-cli-preprocess-registry": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/ember-cli-preprocess-registry/-/ember-cli-preprocess-registry-3.1.1.tgz", + "integrity": "sha1-OEVsIcTStklFhQz57Gjba6dpKIo=", + "requires": { + "broccoli-clean-css": "1.1.0", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "debug": "2.6.9", + "ember-cli-lodash-subset": "1.0.12", + "exists-sync": "0.0.3", + "process-relative-require": "1.0.0", + "silent-error": "1.1.0" + }, + "dependencies": { + "ember-cli-lodash-subset": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/ember-cli-lodash-subset/-/ember-cli-lodash-subset-1.0.12.tgz", + "integrity": "sha1-ry5366XcsNd/MwjTpv19NFD25Tc=" + }, + "exists-sync": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/exists-sync/-/exists-sync-0.0.3.tgz", + "integrity": "sha1-uRAAC+27ETs3i4L19adjgQdiLc8=" + } + } + }, + "ember-cli-sass": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/ember-cli-sass/-/ember-cli-sass-7.1.7.tgz", + "integrity": "sha512-pbqj4/O9WtXQFeFSJkt04yozePl7ksa39veSDIZwPMCAgGnfD0MEXFrn6spprN2YSrFZM0GsUtk/jwYh8x+Qow==", + "requires": { + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-sass-source-maps": "2.2.0", + "ember-cli-version-checker": "2.1.0" + } + }, + "ember-cli-shims": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ember-cli-shims/-/ember-cli-shims-1.2.0.tgz", + "integrity": "sha1-D1Ov8Kq4C18p2jqXMbrFYWndlB8=", + "requires": { + "broccoli-file-creator": "1.1.1", + "broccoli-merge-trees": "2.0.0", + "ember-cli-version-checker": "2.1.0", + "ember-rfc176-data": "0.3.1", + "silent-error": "1.1.0" + }, + "dependencies": { + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + } + } + }, + "ember-cli-string-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ember-cli-string-utils/-/ember-cli-string-utils-1.1.0.tgz", + "integrity": "sha1-ObZ3/CgF9VFzc1N2/O8njqpEUqE=" + }, + "ember-cli-test-loader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ember-cli-test-loader/-/ember-cli-test-loader-2.2.0.tgz", + "integrity": "sha512-mlSXX9SciIRwGkFTX6XGyJYp4ry6oCFZRxh5jJ7VH8UXLTNx2ZACtDTwaWtNhYrWXgKyiDUvmD8enD56aePWRA==", + "requires": { + "ember-cli-babel": "6.12.0" + } + }, + "ember-cli-uglify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ember-cli-uglify/-/ember-cli-uglify-2.0.2.tgz", + "integrity": "sha512-2+bQoy+WNg1BmED1Yq7OooNRlhwDoYZUYya2jxBxFzCnS7MIYMKRFIdvdLQHZ4XP/wllZXfJqUjf9AAInVxXxg==", + "requires": { + "broccoli-uglify-sourcemap": "2.0.2", + "lodash.defaultsdeep": "4.6.0" + } + }, + "ember-cli-valid-component-name": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-cli-valid-component-name/-/ember-cli-valid-component-name-1.0.0.tgz", + "integrity": "sha1-cVUM44fgIzBl8wswsVEKot++h+8=", + "requires": { + "silent-error": "1.1.0" + } + }, + "ember-cli-version-checker": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ember-cli-version-checker/-/ember-cli-version-checker-2.1.0.tgz", + "integrity": "sha512-ssiNyVTp+PphroFum8guHX9py4xU1PCxkRYgb25NxumgjpKTPjhkgTfpRRKXlIQe+/wVMmhf+Uv6w9vSLZKWKQ==", + "requires": { + "resolve": "1.5.0", + "semver": "5.5.0" + } + }, + "ember-component-css": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ember-component-css/-/ember-component-css-0.6.3.tgz", + "integrity": "sha512-uBMZCm3wJFohfQq/RlEAKu+hmwrfHJtZ23w3MiQ/imKTqNwLJkCNZaG5yV5sPtBlIGEvce30UOxnDPGKJqfjyg==", + "requires": { + "broccoli-concat": "3.2.2", + "broccoli-funnel": "2.0.1", + "broccoli-merge-trees": "3.0.0", + "broccoli-persistent-filter": "1.4.3", + "broccoli-plugin": "1.3.0", + "broccoli-style-manifest": "1.5.0", + "ember-cli-babel": "6.12.0", + "ember-getowner-polyfill": "2.2.0", + "fs-tree-diff": "0.5.7", + "md5": "2.2.1", + "postcss": "6.0.19", + "postcss-less": "1.1.3", + "postcss-scss": "1.0.4", + "postcss-selector-namespace": "1.5.0", + "rsvp": "4.8.2", + "walk-sync": "0.3.2" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-merge-trees": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-3.0.0.tgz", + "integrity": "sha512-yyk4J3KSeohlzsmVaRx7ZgAq57K2wzyVtGDaARLG/WuTNlRjKeYEW+atxblvrf0zAOsYMOi8YCpMLRBQUa9jjg==", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "2.0.0" + } + }, + "merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-trees/-/merge-trees-2.0.0.tgz", + "integrity": "sha512-5xBbmqYBalWqmhYm51XlohhkmVOua3VAUrrWh8t9iOkaLpS6ifqm/UVuUjQCeDVJ9Vx3g2l6ihfkbLSTeKsHbw==", + "requires": { + "fs-updater": "1.0.4", + "heimdalljs": "0.2.5" + } + }, + "rsvp": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.2.tgz", + "integrity": "sha512-8CU1Wjxvzt6bt8zln+hCjyieneU9s0LRW+lPRsjyVCY8Vm1kTbK7btBIrCGg6yY9U4undLDm/b1hKEEi1tLypg==" + } + } + }, + "ember-concurrency": { + "version": "0.8.16", + "resolved": "https://registry.npmjs.org/ember-concurrency/-/ember-concurrency-0.8.16.tgz", + "integrity": "sha1-l5+rb7fjEF5pXe+znnpLZBI3lE0=", + "requires": { + "babel-core": "6.26.0", + "ember-cli-babel": "6.12.0", + "ember-maybe-import-regenerator": "0.1.6" + } + }, + "ember-factory-for-polyfill": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ember-factory-for-polyfill/-/ember-factory-for-polyfill-1.3.1.tgz", + "integrity": "sha512-y3iG2iCzH96lZMTWQw6LWNLAfOmDC4pXKbZP6FxG8lt7GGaNFkZjwsf+Z5GAe7kxfD7UG4lVkF7x37K82rySGA==", + "requires": { + "ember-cli-version-checker": "2.1.0" + } + }, + "ember-fetch": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/ember-fetch/-/ember-fetch-3.4.4.tgz", + "integrity": "sha512-N81LZl/NEaQiK4KnLHuibrIZ7goxuIarrQ+A6ZBE/aQCI2x23IISL/YgX0U4bL2F+9I6Kg9ZO2gI+4tszGQSEQ==", + "requires": { + "broccoli-funnel": "1.2.0", + "broccoli-stew": "1.5.0", + "broccoli-templater": "1.0.0", + "ember-cli-babel": "6.12.0", + "node-fetch": "2.1.1", + "whatwg-fetch": "2.0.3" + } + }, + "ember-getowner-polyfill": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ember-getowner-polyfill/-/ember-getowner-polyfill-2.2.0.tgz", + "integrity": "sha512-rwGMJgbGzxIAiWYjdpAh04Abvt0s3HuS/VjHzUFhVyVg2pzAuz45B9AzOxYXzkp88vFC7FPaiA4kE8NxNk4A4Q==", + "requires": { + "ember-cli-version-checker": "2.1.0", + "ember-factory-for-polyfill": "1.3.1" + } + }, + "ember-i18n": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ember-i18n/-/ember-i18n-5.2.0.tgz", + "integrity": "sha512-jTErrSMYeDdmUJ+OOg2HeT/+fjNlphTp4zpPQwDGQUTQyS1RpRN2aztcckLERb8VTvQrpkWKieWQkShVAZcQDQ==", + "requires": { + "broccoli-funnel": "2.0.1", + "ember-cli-babel": "6.12.0", + "ember-cli-version-checker": "2.1.0", + "ember-getowner-polyfill": "2.2.0" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + } + } + }, + "ember-inline-svg": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/ember-inline-svg/-/ember-inline-svg-0.1.11.tgz", + "integrity": "sha1-u1ryTO8ds6suGorsVDvuJio2jDc=", + "requires": { + "broccoli-caching-writer": "3.0.3", + "broccoli-flatiron": "0.0.0", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "2.0.0", + "ember-cli-babel": "5.2.4", + "merge": "1.2.0", + "mkdirp": "0.5.1", + "promise-map-series": "0.2.3", + "rsvp": "3.6.2", + "svgo": "0.6.6", + "walk-sync": "0.3.2" + }, + "dependencies": { + "babel-core": { + "version": "5.8.38", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-5.8.38.tgz", + "integrity": "sha1-H8ruedfmG3ULALjlT238nQr4ZVg=", + "requires": { + "babel-plugin-constant-folding": "1.0.1", + "babel-plugin-dead-code-elimination": "1.0.2", + "babel-plugin-eval": "1.0.1", + "babel-plugin-inline-environment-variables": "1.0.1", + "babel-plugin-jscript": "1.0.4", + "babel-plugin-member-expression-literals": "1.0.1", + "babel-plugin-property-literals": "1.0.1", + "babel-plugin-proto-to-assign": "1.0.4", + "babel-plugin-react-constant-elements": "1.0.3", + "babel-plugin-react-display-name": "1.0.3", + "babel-plugin-remove-console": "1.0.1", + "babel-plugin-remove-debugger": "1.0.1", + "babel-plugin-runtime": "1.0.7", + "babel-plugin-undeclared-variables-check": "1.0.2", + "babel-plugin-undefined-to-void": "1.1.6", + "babylon": "5.8.38", + "bluebird": "2.11.0", + "chalk": "1.1.3", + "convert-source-map": "1.1.3", + "core-js": "1.2.7", + "debug": "2.6.9", + "detect-indent": "3.0.1", + "esutils": "2.0.2", + "fs-readdir-recursive": "0.1.2", + "globals": "6.4.1", + "home-or-tmp": "1.0.0", + "is-integer": "1.0.7", + "js-tokens": "1.0.1", + "json5": "0.4.0", + "lodash": "3.10.1", + "minimatch": "2.0.10", + "output-file-sync": "1.1.2", + "path-exists": "1.0.0", + "path-is-absolute": "1.0.1", + "private": "0.1.8", + "regenerator": "0.8.40", + "regexpu": "1.3.0", + "repeating": "1.1.3", + "resolve": "1.5.0", + "shebang-regex": "1.0.0", + "slash": "1.0.0", + "source-map": "0.5.7", + "source-map-support": "0.2.10", + "to-fast-properties": "1.0.3", + "trim-right": "1.0.1", + "try-resolve": "1.0.1" + } + }, + "babylon": { + "version": "5.8.38", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-5.8.38.tgz", + "integrity": "sha1-7JsSCxG/bM1Bc6GL8hfmC3mFn/0=" + }, + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + }, + "broccoli-babel-transpiler": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/broccoli-babel-transpiler/-/broccoli-babel-transpiler-5.7.4.tgz", + "integrity": "sha512-gI14Pqc4qbmn5RW4SuAmybLiOoYW59D+HzQyhY6WdaGMAjikKBwJN0p17phyvafQ+kvG0mUiMd83lgHLeATnEA==", + "requires": { + "babel-core": "5.8.38", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "broccoli-persistent-filter": "1.4.3", + "clone": "0.2.0", + "hash-for-dep": "1.2.3", + "heimdalljs-logger": "0.1.9", + "json-stable-stringify": "1.0.1", + "rsvp": "3.6.2", + "workerpool": "2.3.0" + }, + "dependencies": { + "broccoli-merge-trees": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-1.2.4.tgz", + "integrity": "sha1-oAFRm7UGfwZYnZGvopQkRaLQ/bU=", + "requires": { + "broccoli-plugin": "1.3.0", + "can-symlink": "1.0.0", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0" + } + }, + "clone": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz", + "integrity": "sha1-xhJqkK1Pctv1rNskPMN3JP6T/B8=" + } + } + }, + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + }, + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "detect-indent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-3.0.1.tgz", + "integrity": "sha1-ncXl3bzu+DJXZLlFGwK8bVQIT3U=", + "requires": { + "get-stdin": "4.0.1", + "minimist": "1.2.0", + "repeating": "1.1.3" + } + }, + "ember-cli-babel": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ember-cli-babel/-/ember-cli-babel-5.2.4.tgz", + "integrity": "sha1-XOT0awjtb20h6Hhhn7aJcZ1ujhM=", + "requires": { + "broccoli-babel-transpiler": "5.7.4", + "broccoli-funnel": "1.2.0", + "clone": "2.1.1", + "ember-cli-version-checker": "1.3.1", + "resolve": "1.5.0" + } + }, + "ember-cli-version-checker": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ember-cli-version-checker/-/ember-cli-version-checker-1.3.1.tgz", + "integrity": "sha1-C8LRNMgwFC2mS/lieg7e0QthrnI=", + "requires": { + "semver": "5.5.0" + } + }, + "globals": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/globals/-/globals-6.4.1.tgz", + "integrity": "sha1-hJgDKzttHMge68X3lpDY/in6v08=" + }, + "home-or-tmp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-1.0.0.tgz", + "integrity": "sha1-S58eQIAMPlDGwn94FnavzOcfOYU=", + "requires": { + "os-tmpdir": "1.0.2", + "user-home": "1.1.1" + } + }, + "js-tokens": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.1.tgz", + "integrity": "sha1-zENaXIuUrRWst5gxQPyAGCyJrq4=" + }, + "json5": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz", + "integrity": "sha1-BUNS5MTIDIbAkjh31EneF2pzLI0=" + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + }, + "minimatch": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", + "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", + "requires": { + "brace-expansion": "1.1.11" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "path-exists": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-1.0.0.tgz", + "integrity": "sha1-1aiZjrce83p0w06w2eum6HjuoIE=" + }, + "repeating": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz", + "integrity": "sha1-PUEUIYh3U3SU+X93+Xhfq4EPpKw=", + "requires": { + "is-finite": "1.0.2" + } + }, + "source-map-support": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.2.10.tgz", + "integrity": "sha1-6lo5AKHByyUJagrozFwrSxDe09w=", + "requires": { + "source-map": "0.1.32" + }, + "dependencies": { + "source-map": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz", + "integrity": "sha1-yLbBZ3l7pHQKjqMyUhYv8IWRsmY=", + "requires": { + "amdefine": "1.0.1" + } + } + } + } + } + }, + "ember-load-initializers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ember-load-initializers/-/ember-load-initializers-1.0.0.tgz", + "integrity": "sha1-SRnq8G9t/sp+E0Yz2MBabJkh5uc=", + "requires": { + "ember-cli-babel": "6.12.0" + } + }, + "ember-maybe-import-regenerator": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ember-maybe-import-regenerator/-/ember-maybe-import-regenerator-0.1.6.tgz", + "integrity": "sha1-NdQYKK+m1qWbwNo85H80xXPXdso=", + "requires": { + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "1.2.4", + "ember-cli-babel": "6.12.0", + "regenerator-runtime": "0.9.6" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz", + "integrity": "sha1-0z65XQ0gAaS+OWWXB8UbDLcc4Ck=" + } + } + }, + "ember-maybe-in-element": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/ember-maybe-in-element/-/ember-maybe-in-element-0.1.3.tgz", + "integrity": "sha512-cAiG6N9HwvoPsMIePgwECilPrKRrIdfKqx9g8qWHKPS4vwrgS2PTeLmOcJvVYbBTXkHaFZmecDRpf6xAj6zk7A==", + "requires": { + "ember-cli-babel": "6.12.0" + } + }, + "ember-mocha": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/ember-mocha/-/ember-mocha-0.13.1.tgz", + "integrity": "sha1-bELXb8flV5ocqEmWITF7bN57Q4E=", + "requires": { + "@ember/test-helpers": "0.7.18", + "broccoli-funnel": "2.0.1", + "broccoli-merge-trees": "2.0.0", + "common-tags": "1.7.2", + "ember-cli-babel": "6.12.0", + "ember-cli-test-loader": "2.2.0", + "mocha": "2.5.3" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + }, + "commander": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", + "integrity": "sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM=" + }, + "diff": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz", + "integrity": "sha1-fyjS657nsVqX79ic5j3P2qPMur8=" + }, + "escape-string-regexp": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz", + "integrity": "sha1-Tbwv5nTnGUnK8/smlc5/LcHZqNE=" + }, + "glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", + "integrity": "sha1-Spc/Y1uRkPcV0QmH1cAP0oFevj0=", + "requires": { + "inherits": "2.0.3", + "minimatch": "0.3.0" + }, + "dependencies": { + "minimatch": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", + "integrity": "sha1-J12O2qxPG7MyZHIInnlJyDlGmd0=", + "requires": { + "lru-cache": "2.7.3", + "sigmund": "1.0.1" + } + } + } + }, + "lru-cache": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" + }, + "mocha": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", + "integrity": "sha1-FhvlvetJZ3HrmzV0UFC2IrWu/Fg=", + "requires": { + "commander": "2.3.0", + "debug": "2.2.0", + "diff": "1.4.0", + "escape-string-regexp": "1.0.2", + "glob": "3.2.11", + "growl": "1.9.2", + "jade": "0.26.3", + "mkdirp": "0.5.1", + "supports-color": "1.2.0", + "to-iso-string": "0.0.2" + }, + "dependencies": { + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + } + } + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + }, + "supports-color": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.2.0.tgz", + "integrity": "sha1-/x7R5hFp0Gs88tWI4YixjYhH4X4=" + } + } + }, + "ember-native-dom-helpers": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/ember-native-dom-helpers/-/ember-native-dom-helpers-0.5.10.tgz", + "integrity": "sha512-bPJX49vlgnBGwFn/3WJPPJjjyd7/atvzW5j01u1dbyFf3bXvHg9Rs1qaZJdk8js0qZ1FINadIEC9vWtgN3w7tg==", + "requires": { + "broccoli-funnel": "1.2.0", + "ember-cli-babel": "6.12.0" + } + }, + "ember-power-select": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/ember-power-select/-/ember-power-select-2.0.0-beta.3.tgz", + "integrity": "sha512-bnVlZ+pISOcEvWqFvDli4flNyeyB/8OT0FAqLBQYwW6vfafs2BpXXyIGFzbzV98C+xHLbwr+0T3FR7s8eycNYw==", + "requires": { + "ember-basic-dropdown": "1.0.0-beta.3", + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "2.0.3", + "ember-concurrency": "0.8.16", + "ember-text-measurer": "0.4.0", + "ember-truth-helpers": "2.0.0" + } + }, + "ember-radio-button": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ember-radio-button/-/ember-radio-button-1.2.3.tgz", + "integrity": "sha512-PQraN4WSQ9yXcaZfIMlX2l3rpGomDRZm/L6q7JethdF4coq9AsIdwJInSdNc7/g76aY4KUJb5FKVj8qSWz/fmA==", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "1.3.4" + }, + "dependencies": { + "ember-cli-htmlbars": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/ember-cli-htmlbars/-/ember-cli-htmlbars-1.3.4.tgz", + "integrity": "sha512-5lycG6z35QHr3WZF1OkVvT+N/GGAVuemtM6m8NUgBWoeA2TqOgPFRcI0eRqoLA0HAfe0R2MReKmMI7y1LEM1+w==", + "requires": { + "broccoli-persistent-filter": "1.4.3", + "ember-cli-version-checker": "1.3.1", + "hash-for-dep": "1.2.3", + "json-stable-stringify": "1.0.1", + "strip-bom": "2.0.0" + } + }, + "ember-cli-version-checker": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ember-cli-version-checker/-/ember-cli-version-checker-1.3.1.tgz", + "integrity": "sha1-C8LRNMgwFC2mS/lieg7e0QthrnI=", + "requires": { + "semver": "5.5.0" + } + } + } + }, + "ember-resolver": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/ember-resolver/-/ember-resolver-4.5.4.tgz", + "integrity": "sha512-+E6hkZIRm5eq/0B8Ip/OL1Zcg7rqoP7qPOT3M8VLjwyfysr/cFRxYBVIsJbtbAZqn4g1S0TPC3bvdAvc/1+T0Q==", + "requires": { + "@glimmer/resolver": "0.4.3", + "babel-plugin-debug-macros": "0.1.11", + "broccoli-funnel": "1.2.0", + "broccoli-merge-trees": "2.0.0", + "ember-cli-babel": "6.12.0", + "ember-cli-version-checker": "2.1.0", + "resolve": "1.5.0" + }, + "dependencies": { + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + } + } + }, + "ember-rfc176-data": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ember-rfc176-data/-/ember-rfc176-data-0.3.1.tgz", + "integrity": "sha512-u+W5rUvYO7xyKJjiPuCM7bIAvFyPwPTJ66fOZz1xuCv3AyReI9Oev5oOADOO6YJZk+vEn0xWiZ9N6zSf8WU7Fg==" + }, + "ember-router-generator": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ember-router-generator/-/ember-router-generator-1.2.3.tgz", + "integrity": "sha1-jtLKhv8yM2MSD8FCeBkeno8TFe4=", + "requires": { + "recast": "0.11.23" + }, + "dependencies": { + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + }, + "recast": { + "version": "0.11.23", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", + "integrity": "sha1-RR/TAEqx5N+bTktmN2sqIZEkYtM=", + "requires": { + "ast-types": "0.9.6", + "esprima": "3.1.3", + "private": "0.1.8", + "source-map": "0.5.7" + } + } + } + }, + "ember-runtime-enumerable-includes-polyfill": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ember-runtime-enumerable-includes-polyfill/-/ember-runtime-enumerable-includes-polyfill-2.1.0.tgz", + "integrity": "sha512-au18iI8VbEDYn3jLFZzETnKN5ciPgCUxMRucEP3jkq7qZ6sE0FVKpWMPY/h9tTND3VOBJt6fgPpEBJoJVCUudg==", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-version-checker": "2.1.0" + } + }, + "ember-sinon": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ember-sinon/-/ember-sinon-1.0.1.tgz", + "integrity": "sha1-BWOQ6syTZ7TDlVzhy1oEJG+Bl/U=", + "requires": { + "broccoli-funnel": "2.0.1", + "broccoli-merge-trees": "2.0.0", + "ember-cli-babel": "6.12.0", + "sinon": "3.3.0" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + } + } + }, + "ember-source": { + "version": "3.1.0-beta.5", + "resolved": "https://registry.npmjs.org/ember-source/-/ember-source-3.1.0-beta.5.tgz", + "integrity": "sha512-dUoY9DsfqtabLZAXKrKhr2V0vGgbJC2B+QwiK5y70BBwZ4FtfQqDJnR94vcJNpKJOWWdukdccc5QwaCXZeMzJQ==", + "requires": { + "broccoli-funnel": "2.0.1", + "broccoli-merge-trees": "2.0.0", + "ember-cli-get-component-path-option": "1.0.0", + "ember-cli-is-package-missing": "1.0.0", + "ember-cli-normalize-entity-name": "1.0.0", + "ember-cli-path-utils": "1.0.0", + "ember-cli-string-utils": "1.1.0", + "ember-cli-valid-component-name": "1.0.0", + "ember-cli-version-checker": "2.1.0", + "ember-router-generator": "1.2.3", + "inflection": "1.12.0", + "jquery": "3.3.1", + "resolve": "1.5.0" + }, + "dependencies": { + "broccoli-funnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/broccoli-funnel/-/broccoli-funnel-2.0.1.tgz", + "integrity": "sha512-C8Lnp9TVsSSiZMGEF16C0dCiNg2oJqUKwuZ1K4kVC6qRPG/2Cj/rtB5kRCC9qEbwqhX71bDbfHROx0L3J7zXQg==", + "requires": { + "array-equal": "1.0.0", + "blank-object": "1.0.2", + "broccoli-plugin": "1.3.0", + "debug": "2.6.9", + "fast-ordered-set": "1.0.3", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "path-posix": "1.0.0", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0", + "walk-sync": "0.3.2" + } + }, + "broccoli-merge-trees": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/broccoli-merge-trees/-/broccoli-merge-trees-2.0.0.tgz", + "integrity": "sha1-EK6kbdXOvMi499WlTwqEpPC7kLk=", + "requires": { + "broccoli-plugin": "1.3.0", + "merge-trees": "1.0.1" + } + } + } + }, + "ember-text-measurer": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/ember-text-measurer/-/ember-text-measurer-0.4.0.tgz", + "integrity": "sha512-gfDb1Id/SH4j5aD52foDD6Pa0xB5Z8pY51FBxKzt6w91NBIgFKN3i8mhCXlp4ue572xax46+vrcel3tbBhD0eA==", + "requires": { + "ember-cli-babel": "6.12.0" + } + }, + "ember-truth-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ember-truth-helpers/-/ember-truth-helpers-2.0.0.tgz", + "integrity": "sha512-B28rHIXi4nIoMOxiCLTNfz5m0b0kfpIM49Sna5/9q44drLuDInVH1H6dRBkG5ROa6zR/md36C/uMyTofjvnI5Q==", + "requires": { + "ember-cli-babel": "6.12.0" + } + }, + "ember-wormhole": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/ember-wormhole/-/ember-wormhole-0.5.4.tgz", + "integrity": "sha1-lo6A8JNJT0rtJm51CvpjkZxhOD0=", + "requires": { + "ember-cli-babel": "6.12.0", + "ember-cli-htmlbars": "2.0.3" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "engine.io": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.0.tgz", + "integrity": "sha1-PutfJky3XbvsG6rqJtYfWk6s4qo=", + "requires": { + "accepts": "1.3.3", + "base64id": "0.1.0", + "cookie": "0.3.1", + "debug": "2.3.3", + "engine.io-parser": "1.3.1", + "ws": "1.1.1" + }, + "dependencies": { + "accepts": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", + "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "requires": { + "mime-types": "2.1.18", + "negotiator": "0.6.1" + } + }, + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + } + } + }, + "engine.io-client": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.0.tgz", + "integrity": "sha1-e3MOQSdBQIdZbZvjyI0rxf22z1w=", + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "2.3.3", + "engine.io-parser": "1.3.1", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parsejson": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "1.1.1", + "xmlhttprequest-ssl": "1.5.3", + "yeast": "0.1.2" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + } + } + }, + "engine.io-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.1.tgz", + "integrity": "sha1-lVTxrjMQfW+9FwylRm0vgz9qB88=", + "requires": { + "after": "0.8.1", + "arraybuffer.slice": "0.0.6", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.4", + "has-binary": "0.1.6", + "wtf-8": "1.0.0" + }, + "dependencies": { + "has-binary": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.6.tgz", + "integrity": "sha1-JTJvOc+k9hath4eJTjryz7x7bhA=", + "requires": { + "isarray": "0.0.1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + } + } + }, + "ensure-posix-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ensure-posix-path/-/ensure-posix-path-1.0.2.tgz", + "integrity": "sha1-pls+QtC3HPxYXrd0+ZQ8jZuRsMI=" + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=" + }, + "error": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", + "integrity": "sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI=", + "requires": { + "string-template": "0.2.1", + "xtend": "4.0.1" + } + }, + "error-ex": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", + "requires": { + "is-arrayish": "0.2.1" + } + }, + "es5-ext": { + "version": "0.10.40", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.40.tgz", + "integrity": "sha512-S9Fh3oya5OOvYSNGvPZJ+vyrs6VYpe1IXPowVe3N1OhaiwVaGlwfn3Zf5P5klYcWOA0toIwYQW8XEv/QqhdHvQ==", + "requires": { + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40", + "es6-symbol": "3.1.1" + } + }, + "es6-map": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", + "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40", + "es6-iterator": "2.0.3", + "es6-set": "0.1.5", + "es6-symbol": "3.1.1", + "event-emitter": "0.3.5" + } + }, + "es6-set": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", + "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40", + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1", + "event-emitter": "0.3.5" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40", + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "escope": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz", + "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", + "requires": { + "es6-map": "0.1.5", + "es6-weak-map": "2.0.2", + "esrecurse": "4.2.1", + "estraverse": "4.2.0" + } + }, + "eslint": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.0.tgz", + "integrity": "sha512-r83L5CuqaocDvfwdojbz68b6tCUk8KJkqfppO+gmSAQqYCzTr0bCSMu6A6yFCLKG65j5eKcKUw4Cw4Yl4gfWkg==", + "dev": true, + "requires": { + "ajv": "5.5.2", + "babel-code-frame": "6.26.0", + "chalk": "2.3.2", + "concat-stream": "1.6.1", + "cross-spawn": "5.1.0", + "debug": "3.1.0", + "doctrine": "2.1.0", + "eslint-scope": "3.7.1", + "eslint-visitor-keys": "1.0.0", + "espree": "3.5.4", + "esquery": "1.0.0", + "esutils": "2.0.2", + "file-entry-cache": "2.0.0", + "functional-red-black-tree": "1.0.1", + "glob": "7.1.2", + "globals": "11.3.0", + "ignore": "3.3.7", + "imurmurhash": "0.1.4", + "inquirer": "3.3.0", + "is-resolvable": "1.1.0", + "js-yaml": "3.11.0", + "json-stable-stringify-without-jsonify": "1.0.1", + "levn": "0.3.0", + "lodash": "4.17.5", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "natural-compare": "1.4.0", + "optionator": "0.8.2", + "path-is-inside": "1.0.2", + "pluralize": "7.0.0", + "progress": "2.0.0", + "regexpp": "1.0.1", + "require-uncached": "1.0.3", + "semver": "5.5.0", + "strip-ansi": "4.0.0", + "strip-json-comments": "2.0.1", + "table": "4.0.2", + "text-table": "0.2.0" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.0.0.tgz", + "integrity": "sha512-O/klc27mWNUigtv0F8NJWbLF00OcegQalkqKURWdosW08YZKi4m6CnSUSvIZG1otNJbTWhN01Hhz389DW7mvDQ==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "dev": true, + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "2.0.0" + } + }, + "concat-stream": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.1.tgz", + "integrity": "sha512-gslSSJx03QKa59cIKqeJO9HQ/WZMotvYJCuaUULrLpjj8oG40kV2Z+gz82pVxlTkOADi4PJxQPPfhl1ELYrrXw==", + "dev": true, + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.5", + "typedarray": "0.0.6" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "external-editor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.1.0.tgz", + "integrity": "sha512-E44iT5QVOUJBKij4IIV3uvxuNlbKS38Tw1HiupxEIHPv9qtC2PrDYohbXV5U+1jnfIXttny8gUhj+oZvflFlzA==", + "dev": true, + "requires": { + "chardet": "0.4.2", + "iconv-lite": "0.4.19", + "tmp": "0.0.33" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "globals": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.3.0.tgz", + "integrity": "sha512-kkpcKNlmQan9Z5ZmgqKH/SMbSmjxQ7QjyNqfXVc8VJcoBV2UEg+sxQD15GQofGRh2hfpwUb70VC31DR7Rq5Hdw==", + "dev": true + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "dev": true, + "requires": { + "ansi-escapes": "3.0.0", + "chalk": "2.3.2", + "cli-cursor": "2.1.0", + "cli-width": "2.2.0", + "external-editor": "2.1.0", + "figures": "2.0.0", + "lodash": "4.17.5", + "mute-stream": "0.0.7", + "run-async": "2.3.0", + "rx-lite": "4.0.8", + "rx-lite-aggregates": "4.0.8", + "string-width": "2.1.1", + "strip-ansi": "4.0.0", + "through": "2.3.8" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "1.2.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "2.0.1", + "signal-exit": "3.0.2" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "1.0.2" + } + } + } + }, + "eslint-config-prettier": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-2.9.0.tgz", + "integrity": "sha512-ag8YEyBXsm3nmOv1Hz991VtNNDMRa+MNy8cY47Pl4bw6iuzqKbJajXdqUpiw13STdLLrznxgm1hj9NhxeOYq0A==", + "dev": true, + "requires": { + "get-stdin": "5.0.1" + }, + "dependencies": { + "get-stdin": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz", + "integrity": "sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g=", + "dev": true + } + } + }, + "eslint-plugin-ember": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-ember/-/eslint-plugin-ember-5.1.0.tgz", + "integrity": "sha512-n7U3Jic4WSHziXs+Gf+41dyNr4KzKBH+Cs95ahAmvXQOMbvElpk/YjdVE0NThr+SXXZYGaXkqP/7IDVhFedieg==", + "dev": true, + "requires": { + "ember-rfc176-data": "0.2.7", + "require-folder-tree": "1.4.5", + "snake-case": "2.1.0" + }, + "dependencies": { + "ember-rfc176-data": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/ember-rfc176-data/-/ember-rfc176-data-0.2.7.tgz", + "integrity": "sha512-pJE2w+sI22UDsYmudI4nCp3WcImpUzXwe9qHfpOcEu3yM/HD1nGpDRt6kZD0KUnDmqkLeik/nYyzEwN/NU6xxA==", + "dev": true + } + } + }, + "eslint-scope": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz", + "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=", + "dev": true, + "requires": { + "esrecurse": "4.2.1", + "estraverse": "4.2.0" + } + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "dev": true, + "requires": { + "acorn": "5.5.3", + "acorn-jsx": "3.0.1" + } + }, + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" + }, + "esquery": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.0.tgz", + "integrity": "sha1-z7qLV9f7qT8XKYqKAGoEzaE9gPo=", + "dev": true, + "requires": { + "estraverse": "4.2.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "requires": { + "estraverse": "4.2.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=" + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.40" + } + }, + "eventemitter3": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", + "integrity": "sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg=" + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "events-to-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz", + "integrity": "sha1-LUH1Y+H+QA7Uli/hpNXGp1Od9/Y=" + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "requires": { + "md5.js": "1.3.4", + "safe-buffer": "5.1.1" + } + }, + "exec-file-sync": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/exec-file-sync/-/exec-file-sync-2.0.2.tgz", + "integrity": "sha1-WNRB20bkDebR8w3lvgInhb2J4yg=", + "requires": { + "is-obj": "1.0.1", + "object-assign": "4.1.1", + "spawn-sync": "1.0.15" + } + }, + "exec-sh": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.1.tgz", + "integrity": "sha512-aLt95pexaugVtQerpmE51+4QfWrNc304uez7jvj6fWnN8GeEHpttB8F36n8N7uVhUMbH/1enbxQ9HImZ4w/9qg==", + "requires": { + "merge": "1.2.0" + } + }, + "execa": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.9.0.tgz", + "integrity": "sha512-BbUMBiX4hqiHZUA5+JujIjNb6TyAlp2D5KLheMjMluwOuzcnylDL4AxZYLLn1n2AGB49eSWwyKvvEQoRpnAtmA==", + "requires": { + "cross-spawn": "5.1.0", + "get-stream": "3.0.0", + "is-stream": "1.1.0", + "npm-run-path": "2.0.2", + "p-finally": "1.0.0", + "signal-exit": "3.0.2", + "strip-eof": "1.0.0" + } + }, + "exists-sync": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/exists-sync/-/exists-sync-0.0.4.tgz", + "integrity": "sha1-l0TCxCjMA7AQYNtFTUsS8O88iHk=" + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=" + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" + }, + "expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", + "requires": { + "is-posix-bracket": "0.1.1" + } + }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "requires": { + "fill-range": "2.2.3" + } + }, + "express": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz", + "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", + "requires": { + "accepts": "1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "1.1.2", + "encodeurl": "1.0.2", + "escape-html": "1.0.3", + "etag": "1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "1.1.2", + "on-finished": "2.3.0", + "parseurl": "1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "2.0.3", + "qs": "6.5.1", + "range-parser": "1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "1.4.0", + "type-is": "1.6.16", + "utils-merge": "1.0.1", + "vary": "1.1.2" + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "requires": { + "assign-symbols": "1.0.0", + "is-extendable": "1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "requires": { + "is-plain-object": "2.0.4" + } + } + } + }, + "external-editor": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-1.1.1.tgz", + "integrity": "sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=", + "requires": { + "extend": "3.0.1", + "spawn-sync": "1.0.15", + "tmp": "0.0.29" + }, + "dependencies": { + "tmp": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.29.tgz", + "integrity": "sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=", + "requires": { + "os-tmpdir": "1.0.2" + } + } + } + }, + "extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", + "requires": { + "is-extglob": "1.0.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-ordered-set": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-ordered-set/-/fast-ordered-set-1.0.3.tgz", + "integrity": "sha1-P7s2Y097555PftvbSjV97iXRhOs=", + "requires": { + "blank-object": "1.0.2" + } + }, + "fast-sourcemap-concat": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fast-sourcemap-concat/-/fast-sourcemap-concat-1.2.4.tgz", + "integrity": "sha512-WA/IwVE1Sd3lyVzzDA42pJbINBok2PG/8IJoBwpzi0dIcqFB3batyT8kqA8Z2fginwOKsIl18LczYTa1wd+R9w==", + "requires": { + "chalk": "0.5.1", + "fs-extra": "0.30.0", + "heimdalljs-logger": "0.1.9", + "memory-streams": "0.1.3", + "mkdirp": "0.5.1", + "source-map": "0.4.4", + "source-map-url": "0.3.0", + "sourcemap-validator": "1.0.7" + }, + "dependencies": { + "ansi-regex": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz", + "integrity": "sha1-DY6UaWej2BQ/k+JOKYUl/BsiNfk=" + }, + "ansi-styles": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz", + "integrity": "sha1-6uy/Zs1waIJ2Cy9GkVgrj1XXp94=" + }, + "chalk": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz", + "integrity": "sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ=", + "requires": { + "ansi-styles": "1.1.0", + "escape-string-regexp": "1.0.5", + "has-ansi": "0.1.0", + "strip-ansi": "0.3.0", + "supports-color": "0.2.0" + } + }, + "fs-extra": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.30.0.tgz", + "integrity": "sha1-8jP/zAjU2n1DLapEl3aYnbHfk/A=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "2.4.0", + "klaw": "1.3.1", + "path-is-absolute": "1.0.1", + "rimraf": "2.6.2" + } + }, + "has-ansi": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz", + "integrity": "sha1-hPJlqujA5qiKEtcCKJS3VoiUxi4=", + "requires": { + "ansi-regex": "0.2.1" + } + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "requires": { + "amdefine": "1.0.1" + } + }, + "strip-ansi": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz", + "integrity": "sha1-JfSOoiynkYfzF0pNuHWTR7sSYiA=", + "requires": { + "ansi-regex": "0.2.1" + } + }, + "supports-color": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz", + "integrity": "sha1-2S3iaU6z9nMjlz1649i1W0wiGQo=" + } + } + }, + "faye-websocket": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", + "integrity": "sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=", + "requires": { + "websocket-driver": "0.7.0" + } + }, + "fb-watchman": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.0.tgz", + "integrity": "sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg=", + "requires": { + "bser": "2.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "requires": { + "escape-string-regexp": "1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "1.3.0", + "object-assign": "4.1.1" + } + }, + "filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=" + }, + "filesize": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.0.tgz", + "integrity": "sha512-g5OWtoZWcPI56js1DFhIEqyG9tnu/7sG3foHwgS9KGYFMfsYguI3E+PRVCmtmE96VajQIEMRU2OhN+ME589Gdw==" + }, + "fill-range": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", + "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=", + "requires": { + "is-number": "2.1.0", + "isobject": "2.1.0", + "randomatic": "1.1.7", + "repeat-element": "1.1.2", + "repeat-string": "1.6.1" + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "1.0.2", + "escape-html": "1.0.3", + "on-finished": "2.3.0", + "parseurl": "1.3.2", + "statuses": "1.4.0", + "unpipe": "1.0.0" + } + }, + "find-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-index/-/find-index-1.1.0.tgz", + "integrity": "sha1-UwB8ec0wBA1oFteUWOiDfVxXBe8=" + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + }, + "find-yarn-workspace-root": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-1.0.0.tgz", + "integrity": "sha512-Nrs52BY7Uf5QSQ3/k15uVFQ35q7oZ0BY6auTs8X0Ycina+eSQ1y1vkTT9/a34c+QEE1CfplOmDgyAm/rE//ySA==", + "requires": { + "fs-extra": "4.0.3", + "micromatch": "3.1.9" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=" + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" + }, + "braces": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.1.tgz", + "integrity": "sha512-SO5lYHA3vO6gz66erVvedSCkp7AKWdv6VcQ2N4ysXfPxdAlxAMMAdwegGGcv1Bqwm7naF1hNdk5d6AAIEHV2nQ==", + "requires": { + "arr-flatten": "1.1.0", + "array-unique": "0.3.2", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "fill-range": "4.0.0", + "isobject": "3.0.1", + "kind-of": "6.0.2", + "repeat-element": "1.1.2", + "snapdragon": "0.8.2", + "snapdragon-node": "2.1.1", + "split-string": "3.1.0", + "to-regex": "3.0.2" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "1.0.2" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "requires": { + "debug": "2.6.9", + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "posix-character-classes": "0.1.1", + "regex-not": "1.0.2", + "snapdragon": "0.8.2", + "to-regex": "3.0.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "0.1.6" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "requires": { + "array-unique": "0.3.2", + "define-property": "1.0.0", + "expand-brackets": "2.1.4", + "extend-shallow": "2.0.1", + "fragment-cache": "0.2.1", + "regex-not": "1.0.2", + "snapdragon": "0.8.2", + "to-regex": "3.0.2" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "1.0.2" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "requires": { + "extend-shallow": "2.0.1", + "is-number": "3.0.0", + "repeat-string": "1.6.1", + "to-regex-range": "2.1.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + } + } + }, + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + }, + "micromatch": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.9.tgz", + "integrity": "sha512-SlIz6sv5UPaAVVFRKodKjCg48EbNoIhgetzfK/Cy0v5U52Z6zB136M8tp0UC9jM53LYbmIRihJszvvqpKkfm9g==", + "requires": { + "arr-diff": "4.0.0", + "array-unique": "0.3.2", + "braces": "2.3.1", + "define-property": "2.0.2", + "extend-shallow": "3.0.2", + "extglob": "2.0.4", + "fragment-cache": "0.2.1", + "kind-of": "6.0.2", + "nanomatch": "1.2.9", + "object.pick": "1.3.0", + "regex-not": "1.0.2", + "snapdragon": "0.8.2", + "to-regex": "3.0.2" + } + } + } + }, + "fireworm": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/fireworm/-/fireworm-0.7.1.tgz", + "integrity": "sha1-zPIPeUHxCIg/zduZOD2+bhhhx1g=", + "requires": { + "async": "0.2.10", + "is-type": "0.0.1", + "lodash.debounce": "3.1.1", + "lodash.flatten": "3.0.2", + "minimatch": "3.0.4" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + } + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "0.3.3", + "del": "2.2.2", + "graceful-fs": "4.1.11", + "write": "0.2.1" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" + }, + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "requires": { + "for-in": "1.0.2" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.6", + "mime-types": "2.1.18" + } + }, + "formatio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.2.0.tgz", + "integrity": "sha1-87IWfZBoxGmKjVH092CjmlTYGOs=", + "requires": { + "samsam": "1.3.0" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "requires": { + "map-cache": "0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + }, + "dependencies": { + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + } + } + }, + "fs-readdir-recursive": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-0.1.2.tgz", + "integrity": "sha1-MVtPuMHKW4xH3v7zGdBz2tNWgFk=" + }, + "fs-tree": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-tree/-/fs-tree-1.0.0.tgz", + "integrity": "sha1-72TaPm3TLMDfJ8Oz4MKZ/6V1wCY=", + "requires": { + "mkdirp": "0.5.1", + "rimraf": "2.2.8" + }, + "dependencies": { + "rimraf": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz", + "integrity": "sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=" + } + } + }, + "fs-tree-diff": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/fs-tree-diff/-/fs-tree-diff-0.5.7.tgz", + "integrity": "sha512-dJwDX6NBH7IfdfFjZAdHCZ6fIKc8LwR7kzqUhYRFJuX4g9ctG/7cuqJuwegGQsyLEykp6Z4krq+yIFMQlt7d9Q==", + "requires": { + "heimdalljs-logger": "0.1.9", + "object-assign": "4.1.1", + "path-posix": "1.0.0", + "symlink-or-copy": "1.2.0" + } + }, + "fs-updater": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fs-updater/-/fs-updater-1.0.4.tgz", + "integrity": "sha512-0pJX4mJF/qLsNEwTct8CdnnRdagfb+LmjRPJ8sO+nCnAZLW0cTmz4rTgU25n+RvTuWSITiLKrGVJceJPBIPlKg==", + "requires": { + "can-symlink": "1.0.0", + "clean-up-path": "1.0.0", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "rimraf": "2.6.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz", + "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", + "optional": true, + "requires": { + "nan": "2.9.2", + "node-pre-gyp": "0.6.39" + }, + "dependencies": { + "abbrev": { + "version": "1.1.0", + "bundled": true, + "optional": true + }, + "ajv": { + "version": "4.11.8", + "bundled": true, + "optional": true, + "requires": { + "co": "4.6.0", + "json-stable-stringify": "1.0.1" + } + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true + }, + "aproba": { + "version": "1.1.1", + "bundled": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "optional": true, + "requires": { + "delegates": "1.0.0", + "readable-stream": "2.2.9" + } + }, + "asn1": { + "version": "0.2.3", + "bundled": true, + "optional": true + }, + "assert-plus": { + "version": "0.2.0", + "bundled": true, + "optional": true + }, + "asynckit": { + "version": "0.4.0", + "bundled": true, + "optional": true + }, + "aws-sign2": { + "version": "0.6.0", + "bundled": true, + "optional": true + }, + "aws4": { + "version": "1.6.0", + "bundled": true, + "optional": true + }, + "balanced-match": { + "version": "0.4.2", + "bundled": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "bundled": true, + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "block-stream": { + "version": "0.0.9", + "bundled": true, + "requires": { + "inherits": "2.0.3" + } + }, + "boom": { + "version": "2.10.1", + "bundled": true, + "requires": { + "hoek": "2.16.3" + } + }, + "brace-expansion": { + "version": "1.1.7", + "bundled": true, + "requires": { + "balanced-match": "0.4.2", + "concat-map": "0.0.1" + } + }, + "buffer-shims": { + "version": "1.0.0", + "bundled": true + }, + "caseless": { + "version": "0.12.0", + "bundled": true, + "optional": true + }, + "co": { + "version": "4.6.0", + "bundled": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true + }, + "combined-stream": { + "version": "1.0.5", + "bundled": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true + }, + "cryptiles": { + "version": "2.0.5", + "bundled": true, + "requires": { + "boom": "2.10.1" + } + }, + "dashdash": { + "version": "1.14.1", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "debug": { + "version": "2.6.8", + "bundled": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.4.2", + "bundled": true, + "optional": true + }, + "delayed-stream": { + "version": "1.0.0", + "bundled": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "ecc-jsbn": { + "version": "0.1.1", + "bundled": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "extend": { + "version": "3.0.1", + "bundled": true, + "optional": true + }, + "extsprintf": { + "version": "1.0.2", + "bundled": true + }, + "forever-agent": { + "version": "0.6.1", + "bundled": true, + "optional": true + }, + "form-data": { + "version": "2.1.4", + "bundled": true, + "optional": true, + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.15" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true + }, + "fstream": { + "version": "1.0.11", + "bundled": true, + "requires": { + "graceful-fs": "4.1.11", + "inherits": "2.0.3", + "mkdirp": "0.5.1", + "rimraf": "2.6.1" + } + }, + "fstream-ignore": { + "version": "1.0.5", + "bundled": true, + "optional": true, + "requires": { + "fstream": "1.0.11", + "inherits": "2.0.3", + "minimatch": "3.0.4" + } + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "optional": true, + "requires": { + "aproba": "1.1.1", + "console-control-strings": "1.1.0", + "has-unicode": "2.0.1", + "object-assign": "4.1.1", + "signal-exit": "3.0.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wide-align": "1.1.2" + } + }, + "getpass": { + "version": "0.1.7", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true + }, + "har-schema": { + "version": "1.0.5", + "bundled": true, + "optional": true + }, + "har-validator": { + "version": "4.2.1", + "bundled": true, + "optional": true, + "requires": { + "ajv": "4.11.8", + "har-schema": "1.0.5" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "optional": true + }, + "hawk": { + "version": "3.1.3", + "bundled": true, + "requires": { + "boom": "2.10.1", + "cryptiles": "2.0.5", + "hoek": "2.16.3", + "sntp": "1.0.9" + } + }, + "hoek": { + "version": "2.16.3", + "bundled": true + }, + "http-signature": { + "version": "1.1.1", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "0.2.0", + "jsprim": "1.4.0", + "sshpk": "1.13.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true + }, + "ini": { + "version": "1.3.4", + "bundled": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "bundled": true, + "optional": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true + }, + "isstream": { + "version": "0.1.2", + "bundled": true, + "optional": true + }, + "jodid25519": { + "version": "1.0.2", + "bundled": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "jsbn": { + "version": "0.1.1", + "bundled": true, + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "bundled": true, + "optional": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "bundled": true, + "optional": true, + "requires": { + "jsonify": "0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "bundled": true, + "optional": true + }, + "jsonify": { + "version": "0.0.0", + "bundled": true, + "optional": true + }, + "jsprim": { + "version": "1.4.0", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.0.2", + "json-schema": "0.2.3", + "verror": "1.3.6" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "mime-db": { + "version": "1.27.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.15", + "bundled": true, + "requires": { + "mime-db": "1.27.0" + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "optional": true + }, + "node-pre-gyp": { + "version": "0.6.39", + "bundled": true, + "optional": true, + "requires": { + "detect-libc": "1.0.2", + "hawk": "3.1.3", + "mkdirp": "0.5.1", + "nopt": "4.0.1", + "npmlog": "4.1.0", + "rc": "1.2.1", + "request": "2.81.0", + "rimraf": "2.6.1", + "semver": "5.3.0", + "tar": "2.2.1", + "tar-pack": "3.4.0" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "optional": true, + "requires": { + "abbrev": "1.1.0", + "osenv": "0.1.4" + } + }, + "npmlog": { + "version": "4.1.0", + "bundled": true, + "optional": true, + "requires": { + "are-we-there-yet": "1.1.4", + "console-control-strings": "1.1.0", + "gauge": "2.7.4", + "set-blocking": "2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true + }, + "oauth-sign": { + "version": "0.8.2", + "bundled": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "osenv": { + "version": "0.1.4", + "bundled": true, + "optional": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "performance-now": { + "version": "0.2.0", + "bundled": true, + "optional": true + }, + "process-nextick-args": { + "version": "1.0.7", + "bundled": true + }, + "punycode": { + "version": "1.4.1", + "bundled": true, + "optional": true + }, + "qs": { + "version": "6.4.0", + "bundled": true, + "optional": true + }, + "rc": { + "version": "1.2.1", + "bundled": true, + "optional": true, + "requires": { + "deep-extend": "0.4.2", + "ini": "1.3.4", + "minimist": "1.2.0", + "strip-json-comments": "2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.2.9", + "bundled": true, + "requires": { + "buffer-shims": "1.0.0", + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "1.0.1", + "util-deprecate": "1.0.2" + } + }, + "request": { + "version": "2.81.0", + "bundled": true, + "optional": true, + "requires": { + "aws-sign2": "0.6.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.1.4", + "har-validator": "4.2.1", + "hawk": "3.1.3", + "http-signature": "1.1.1", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.15", + "oauth-sign": "0.8.2", + "performance-now": "0.2.0", + "qs": "6.4.0", + "safe-buffer": "5.0.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.2", + "tunnel-agent": "0.6.0", + "uuid": "3.0.1" + } + }, + "rimraf": { + "version": "2.6.1", + "bundled": true, + "requires": { + "glob": "7.1.2" + } + }, + "safe-buffer": { + "version": "5.0.1", + "bundled": true + }, + "semver": { + "version": "5.3.0", + "bundled": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "optional": true + }, + "sntp": { + "version": "1.0.9", + "bundled": true, + "requires": { + "hoek": "2.16.3" + } + }, + "sshpk": { + "version": "1.13.0", + "bundled": true, + "optional": true, + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jodid25519": "1.0.2", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "stringstream": { + "version": "0.0.5", + "bundled": true, + "optional": true + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "optional": true + }, + "tar": { + "version": "2.2.1", + "bundled": true, + "requires": { + "block-stream": "0.0.9", + "fstream": "1.0.11", + "inherits": "2.0.3" + } + }, + "tar-pack": { + "version": "3.4.0", + "bundled": true, + "optional": true, + "requires": { + "debug": "2.6.8", + "fstream": "1.0.11", + "fstream-ignore": "1.0.5", + "once": "1.4.0", + "readable-stream": "2.2.9", + "rimraf": "2.6.1", + "tar": "2.2.1", + "uid-number": "0.0.6" + } + }, + "tough-cookie": { + "version": "2.3.2", + "bundled": true, + "optional": true, + "requires": { + "punycode": "1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "bundled": true, + "optional": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "bundled": true, + "optional": true + }, + "uid-number": { + "version": "0.0.6", + "bundled": true, + "optional": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true + }, + "uuid": { + "version": "3.0.1", + "bundled": true, + "optional": true + }, + "verror": { + "version": "1.3.6", + "bundled": true, + "optional": true, + "requires": { + "extsprintf": "1.0.2" + } + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "optional": true, + "requires": { + "string-width": "1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true + } + } + }, + "fstream": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "requires": { + "graceful-fs": "4.1.11", + "inherits": "2.0.3", + "mkdirp": "0.5.1", + "rimraf": "2.6.2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "1.2.0", + "console-control-strings": "1.1.0", + "has-unicode": "2.0.1", + "object-assign": "4.1.1", + "signal-exit": "3.0.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wide-align": "1.1.2" + } + }, + "gaze": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.2.tgz", + "integrity": "sha1-hHIkZ3rbiHDWeSV+0ziP22HkAQU=", + "requires": { + "globule": "1.2.0" + } + }, + "generate-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", + "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=" + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "requires": { + "is-property": "1.0.2" + } + }, + "get-caller-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=" + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=" + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "git-repo-info": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/git-repo-info/-/git-repo-info-1.4.1.tgz", + "integrity": "sha1-KgcoIyVKr2L88HZgB9e2ZRvUGUM=" + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", + "requires": { + "glob-parent": "2.0.0", + "is-glob": "2.0.1" + } + }, + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "requires": { + "is-glob": "2.0.1" + } + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==" + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "1.0.2", + "arrify": "1.0.1", + "glob": "7.1.2", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "globule": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.0.tgz", + "integrity": "sha1-HcScaCLdnoovoAuiopUAboZkvQk=", + "requires": { + "glob": "7.1.2", + "lodash": "4.17.5", + "minimatch": "3.0.4" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" + }, + "graphql": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-0.13.2.tgz", + "integrity": "sha512-QZ5BL8ZO/B20VA8APauGBg3GyEgZ19eduvpLWoq5x7gMmWnHoy8rlQWPLmWgFvo1yNgjSEFMesmS4R6pPr7xog==", + "requires": { + "iterall": "1.2.2" + } + }, + "graphql-tag": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.8.0.tgz", + "integrity": "sha1-Us3qB6hCFU7BGi6EDBG5d/m4Nc4=" + }, + "growl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=" + }, + "growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=" + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "requires": { + "async": "1.5.2", + "optimist": "0.6.1", + "source-map": "0.4.4", + "uglify-js": "2.8.29" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "requires": { + "chalk": "1.1.3", + "commander": "2.15.0", + "is-my-json-valid": "2.17.2", + "pinkie-promise": "2.0.1" + }, + "dependencies": { + "commander": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.0.tgz", + "integrity": "sha512-7B1ilBwtYSbetCgTY1NJFg+gVpestg0fdA1MhC1Vs4ssyfSXnCAjFr+QcQM9/RedXC0EaUx1sG8Smgw2VfgKEg==" + } + } + }, + "has": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", + "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", + "requires": { + "function-bind": "1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "requires": { + "ansi-regex": "2.1.1" + } + }, + "has-binary": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz", + "integrity": "sha1-aOYesWIQyVRaClzOBqhzkS/h5ow=", + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "requires": { + "get-value": "2.0.6", + "has-values": "1.0.0", + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "hash-base": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", + "integrity": "sha1-ZuodhW206KVHDK32/OI65SRO8uE=", + "requires": { + "inherits": "2.0.3" + } + }, + "hash-for-dep": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/hash-for-dep/-/hash-for-dep-1.2.3.tgz", + "integrity": "sha512-NE//rDaCFpWHViw30YM78OAGBShU+g4dnUGY3UWGyEzPOGYg/ptOjk32nEc+bC1xz+RfK5UIs6lOL6eQdrV4Ow==", + "requires": { + "broccoli-kitchen-sink-helpers": "0.3.1", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "resolve": "1.5.0" + } + }, + "hash.js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.3.tgz", + "integrity": "sha512-/UETyP0W22QILqS+6HowevwhEFJ3MBJnwTf75Qob9Wz9t0DPuisL8kW8YZMK62dHAKE1c1p+gY1TtOLY+USEHA==", + "requires": { + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", + "requires": { + "boom": "2.10.1", + "cryptiles": "2.0.5", + "hoek": "2.16.3", + "sntp": "1.0.9" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" + }, + "heimdalljs": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.5.tgz", + "integrity": "sha1-aqVDCO7nk7ZCz/nPlHgURfN3MKw=", + "requires": { + "rsvp": "3.2.1" + }, + "dependencies": { + "rsvp": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.2.1.tgz", + "integrity": "sha1-B8tKXfJa3Z6Cbrxn3Mn9idsn2Eo=" + } + } + }, + "heimdalljs-fs-monitor": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/heimdalljs-fs-monitor/-/heimdalljs-fs-monitor-0.2.0.tgz", + "integrity": "sha512-VoCm1pTXqlDtYYHVg5O3hDzO7fCVMEw+B6usW8swsypq2WbOtGBwehBWiK44QcGyOOoy/7mwtz4vVkn8Q0cK7Q==", + "requires": { + "chai": "3.5.0", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "mocha": "3.5.3" + } + }, + "heimdalljs-graph": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/heimdalljs-graph/-/heimdalljs-graph-0.3.4.tgz", + "integrity": "sha512-2DXgPIxdatgtBOjlh5qeVeHIGMTC2V9ujEvUhVJBVOVwqnU41g1OuGaRugLi6rvk0E+u1koYkfPeptybV8ZJ4g==" + }, + "heimdalljs-logger": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/heimdalljs-logger/-/heimdalljs-logger-0.1.9.tgz", + "integrity": "sha1-12raTkW3u294b8nAEKaOsuL68XY=", + "requires": { + "debug": "2.6.9", + "heimdalljs": "0.2.5" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "requires": { + "hash.js": "1.1.3", + "minimalistic-assert": "1.0.0", + "minimalistic-crypto-utils": "1.0.1" + } + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "hosted-git-info": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.6.0.tgz", + "integrity": "sha512-lIbgIIQA3lz5XaB6vxakj6sDHADJiZadYEJB+FgA+C4nubM1NwcuvUr9EJPmnH1skZqpqUzWborWo8EIUi0Sdw==" + }, + "htmlescape": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", + "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=" + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": "1.4.0" + }, + "dependencies": { + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + } + } + }, + "http-parser-js": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.11.tgz", + "integrity": "sha512-QCR5O2AjjMW8Mo4HyI1ctFcv+O99j/0g367V3YoVnrNw5hkDvAWZD0lWGcc+F4yN3V55USPCVix4efb75HxFfA==" + }, + "http-proxy": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.16.2.tgz", + "integrity": "sha1-Bt/ykpUr9k2+hHH6nfcwZtTzd0I=", + "requires": { + "eventemitter3": "1.2.0", + "requires-port": "1.0.0" + } + }, + "http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", + "requires": { + "assert-plus": "0.2.0", + "jsprim": "1.4.1", + "sshpk": "1.14.1" + } + }, + "https-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.1.tgz", + "integrity": "sha1-P5E2XKvmC3ftDruiS0VOPgnZWoI=" + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" + }, + "ignore": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.7.tgz", + "integrity": "sha512-YGG3ejvBNHRqu0559EOxxNFihD0AjpvHlC/pdGKd3X3ofe+CoJkYazwNJYTNebqpPKN+VVQbh4ZFn1DivMNuHA==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "in-publish": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", + "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=" + }, + "include-path-searcher": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/include-path-searcher/-/include-path-searcher-0.1.0.tgz", + "integrity": "sha1-wM8t36Fk+y6uB7x8pDp/GRy0170=" + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "requires": { + "repeating": "2.0.1" + } + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, + "inflection": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", + "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", + "requires": { + "source-map": "0.5.7" + } + }, + "inline-source-map-comment": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/inline-source-map-comment/-/inline-source-map-comment-1.0.5.tgz", + "integrity": "sha1-UKikTCp5DfrEQbXJTszVRiY1+vY=", + "requires": { + "chalk": "1.1.3", + "get-stdin": "4.0.1", + "minimist": "1.2.0", + "sum-up": "1.0.3", + "xtend": "4.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, + "inquirer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-2.0.0.tgz", + "integrity": "sha1-4TUWh7kNFQykA86qPO+x4wZb70s=", + "requires": { + "ansi-escapes": "1.4.0", + "chalk": "1.1.3", + "cli-cursor": "1.0.2", + "cli-width": "2.2.0", + "external-editor": "1.1.1", + "figures": "2.0.0", + "lodash": "4.17.5", + "mute-stream": "0.0.6", + "pinkie-promise": "2.0.1", + "run-async": "2.3.0", + "rx": "4.1.0", + "string-width": "2.1.1", + "strip-ansi": "3.0.1", + "through": "2.3.8" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "3.0.0" + } + } + } + } + } + }, + "insert-module-globals": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.0.2.tgz", + "integrity": "sha512-p3s7g96Nm62MbHRuj9ZXab0DuJNWD7qcmdUXCOQ/ZZn42DtDXfsLill7bq19lDCx3K3StypqUnuE3H2VmIJFUw==", + "requires": { + "JSONStream": "1.3.2", + "combine-source-map": "0.7.2", + "concat-stream": "1.5.2", + "is-buffer": "1.1.6", + "lexical-scope": "1.2.0", + "process": "0.11.10", + "through2": "2.0.3", + "xtend": "4.0.1" + }, + "dependencies": { + "combine-source-map": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.7.2.tgz", + "integrity": "sha1-CHAxKFazB6h8xKxIbzqaYq7MwJ4=", + "requires": { + "convert-source-map": "1.1.3", + "inline-source-map": "0.6.2", + "lodash.memoize": "3.0.4", + "source-map": "0.5.7" + } + } + } + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "1.3.1" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" + }, + "ipaddr.js": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz", + "integrity": "sha1-4/o1e3c9phnybpXwSdBVxyeW+Gs=" + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "requires": { + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "requires": { + "builtin-modules": "1.1.1" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "requires": { + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + } + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "requires": { + "is-accessor-descriptor": "1.0.0", + "is-data-descriptor": "1.0.0", + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + } + } + }, + "is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=" + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "requires": { + "is-primitive": "2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=" + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-git-url": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-git-url/-/is-git-url-1.0.0.tgz", + "integrity": "sha1-U/aEzRQyhbUsMkS05vKCU1J69ms=" + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "requires": { + "is-extglob": "1.0.0" + } + }, + "is-integer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-integer/-/is-integer-1.0.7.tgz", + "integrity": "sha1-a96Bqs3feLZZtmKdYpytxRqIbVw=", + "requires": { + "is-finite": "1.0.2" + } + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==" + }, + "is-my-json-valid": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz", + "integrity": "sha512-IBhBslgngMQN8DDSppmgDv7RNrlFotuuDsKcrCP3+HbFaVivIBU7u9oiiErw8sH4ynx3+gOGQ3q2otkgiSi6kg==", + "requires": { + "generate-function": "2.0.0", + "generate-object-property": "1.2.0", + "is-my-ip-valid": "1.0.0", + "jsonpointer": "4.0.1", + "xtend": "4.0.1" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "requires": { + "kind-of": "3.2.2" + } + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" + }, + "is-odd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-2.0.0.tgz", + "integrity": "sha512-OTiixgpZAT1M4NHgS5IguFp/Vz2VI3U7Goh4/HA1adtwyLtSBrxYlcSYkhpAE07s4fKEcjrFxyvtQBND4vFQyQ==", + "requires": { + "is-number": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==" + } + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz", + "integrity": "sha1-ZHdYK4IU1gI0YJRWcAO+ip6sBNw=", + "dev": true, + "requires": { + "is-path-inside": "1.0.1" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "1.0.2" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=" + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=" + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=" + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-type": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/is-type/-/is-type-0.0.1.tgz", + "integrity": "sha1-9lHYXDZdRJVdFKUdjXBh8/a0d5w=", + "requires": { + "core-util-is": "1.0.2" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isbinaryfile": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.2.tgz", + "integrity": "sha1-Sj6XTsDLqQBNP8bN5yCeppNopiE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istextorbinary": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-2.1.0.tgz", + "integrity": "sha1-2+0qb1G+L3R1to+JRlgRFBt1iHQ=", + "requires": { + "binaryextensions": "2.1.1", + "editions": "1.3.4", + "textextensions": "2.2.0" + } + }, + "iterall": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.2.2.tgz", + "integrity": "sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==" + }, + "jade": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", + "integrity": "sha1-jxDXl32NefL2/4YqgbBRPMslaGw=", + "requires": { + "commander": "0.6.1", + "mkdirp": "0.3.0" + }, + "dependencies": { + "commander": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz", + "integrity": "sha1-+mihT2qUXVTbvlDYzbMyDp47GgY=" + }, + "mkdirp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", + "integrity": "sha1-G79asbqCevI1dRQ0kEJkVfSB/h4=" + } + } + }, + "jquery": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", + "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==" + }, + "js-base64": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.3.tgz", + "integrity": "sha512-H7ErYLM34CvDMto3GbD6xD0JLUGYXR3QTcH6B/tr4Hi/QpSThnCsIp+Sy5FRTw3B0d6py4HcNkW7nO/wdtGWEw==" + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "js-yaml": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.11.0.tgz", + "integrity": "sha512-saJstZWv7oNeOyBh3+Dx1qWzhW0+e6/8eDzo7p5rDFqxntSztloLtuKu+Ejhtq82jsilwOIZYsCz+lIjthg1Hw==", + "requires": { + "argparse": "1.0.10", + "esprima": "4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" + }, + "jsmin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jsmin/-/jsmin-1.0.1.tgz", + "integrity": "sha1-570NzWSWw79IYyNb9GGj2YqjuYw=" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "dev": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "requires": { + "jsonify": "0.0.0" + } + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "requires": { + "graceful-fs": "4.1.11" + } + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==" + }, + "jxLoader": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jxLoader/-/jxLoader-0.1.1.tgz", + "integrity": "sha1-ATTqUUTlM7WU/B/yX/GU4jXFPs0=", + "requires": { + "js-yaml": "0.3.7", + "moo-server": "1.3.0", + "promised-io": "0.3.5", + "walker": "1.0.7" + }, + "dependencies": { + "js-yaml": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-0.3.7.tgz", + "integrity": "sha1-1znY7oZGHlSzVNan19HyrZoWf2I=" + } + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "requires": { + "graceful-fs": "4.1.11" + } + }, + "labeled-stream-splicer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.0.tgz", + "integrity": "sha1-pS4dE4AkwAuGscDJH2d5GLiuClk=", + "requires": { + "inherits": "2.0.3", + "isarray": "0.0.1", + "stream-splicer": "2.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + } + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=" + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "requires": { + "invert-kv": "1.0.0" + } + }, + "leek": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/leek/-/leek-0.0.24.tgz", + "integrity": "sha1-5ADlfw5g2O8r1NBo3EKKVDRdvNo=", + "requires": { + "debug": "2.6.9", + "lodash.assign": "3.2.0", + "rsvp": "3.6.2" + } + }, + "leven": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/leven/-/leven-1.0.2.tgz", + "integrity": "sha1-kUS27ryl8dBoAWnxpncNzqYLdcM=" + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2", + "type-check": "0.3.2" + } + }, + "lexical-scope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/lexical-scope/-/lexical-scope-1.2.0.tgz", + "integrity": "sha1-/Ope3HBKSzqHls3KQZw6CvryLfQ=", + "requires": { + "astw": "2.2.0" + } + }, + "linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=", + "requires": { + "uc.micro": "1.0.5" + } + }, + "livereload-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.3.0.tgz", + "integrity": "sha512-j1R0/FeGa64Y+NmqfZhyoVRzcFlOZ8sNlKzHjh4VvLULFACZhn68XrX5DFg2FhMvSMJmROuFxRSa560ECWKBMg==" + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "requires": { + "graceful-fs": "4.1.11", + "parse-json": "2.2.0", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "strip-bom": "2.0.0" + } + }, + "loader.js": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/loader.js/-/loader.js-4.6.0.tgz", + "integrity": "sha512-NjAnzMq/BM2VlXA9er0Nx1Runocgi+hEU53ENhCtTx82GX6/l9NXwfIqg81om6QvmhUlv2zH+4R+N+wOoeXytQ==" + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "requires": { + "p-locate": "2.0.0", + "path-exists": "3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + } + } + }, + "lodash": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.5.tgz", + "integrity": "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==" + }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "requires": { + "lodash._basecopy": "3.0.1", + "lodash.keys": "3.1.2" + }, + "dependencies": { + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "requires": { + "lodash._getnative": "3.9.1", + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + } + } + }, + "lodash._basebind": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._basebind/-/lodash._basebind-2.3.0.tgz", + "integrity": "sha1-K1vEUqDhBhQ7IYafIzvbWHQX0kg=", + "requires": { + "lodash._basecreate": "2.3.0", + "lodash._setbinddata": "2.3.0", + "lodash.isobject": "2.3.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=" + }, + "lodash._basecreate": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-2.3.0.tgz", + "integrity": "sha1-m4ioak3P97fzxh2Dovz8BnHsneA=", + "requires": { + "lodash._renative": "2.3.0", + "lodash.isobject": "2.3.0", + "lodash.noop": "2.3.0" + } + }, + "lodash._basecreatecallback": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._basecreatecallback/-/lodash._basecreatecallback-2.3.0.tgz", + "integrity": "sha1-N7KrF1kaM56YjbMln81GAZ16w2I=", + "requires": { + "lodash._setbinddata": "2.3.0", + "lodash.bind": "2.3.0", + "lodash.identity": "2.3.0", + "lodash.support": "2.3.0" + } + }, + "lodash._basecreatewrapper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._basecreatewrapper/-/lodash._basecreatewrapper-2.3.0.tgz", + "integrity": "sha1-qgxhrZYETDkzN2ExSDqXWcNlEkc=", + "requires": { + "lodash._basecreate": "2.3.0", + "lodash._setbinddata": "2.3.0", + "lodash._slice": "2.3.0", + "lodash.isobject": "2.3.0" + } + }, + "lodash._baseflatten": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/lodash._baseflatten/-/lodash._baseflatten-3.1.4.tgz", + "integrity": "sha1-B3D/gBMa9uNPO1EXlqe6UhTmX/c=", + "requires": { + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + }, + "lodash._basetostring": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz", + "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=" + }, + "lodash._basevalues": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz", + "integrity": "sha1-W3dXYoAr3j0yl1A+JjAIIP32Ybc=" + }, + "lodash._bindcallback": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz", + "integrity": "sha1-5THCdkTPi1epnhftlbNcdIeJOS4=" + }, + "lodash._createassigner": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz", + "integrity": "sha1-g4pbri/aymOsIt7o4Z+k5taXCxE=", + "requires": { + "lodash._bindcallback": "3.0.1", + "lodash._isiterateecall": "3.0.9", + "lodash.restparam": "3.6.1" + } + }, + "lodash._createwrapper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._createwrapper/-/lodash._createwrapper-2.3.0.tgz", + "integrity": "sha1-0arhEC2t9EDo4G/BM6bt1/4UYHU=", + "requires": { + "lodash._basebind": "2.3.0", + "lodash._basecreatewrapper": "2.3.0", + "lodash.isfunction": "2.3.0" + } + }, + "lodash._escapehtmlchar": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._escapehtmlchar/-/lodash._escapehtmlchar-2.3.0.tgz", + "integrity": "sha1-0D2mvYLu3zjcCltQPXQOzQ6JRZI=", + "requires": { + "lodash._htmlescapes": "2.3.0" + } + }, + "lodash._escapestringchar": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._escapestringchar/-/lodash._escapestringchar-2.3.0.tgz", + "integrity": "sha1-zOc65g/G2lXSv4oGecI8orqxSfw=" + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + }, + "lodash._htmlescapes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._htmlescapes/-/lodash._htmlescapes-2.3.0.tgz", + "integrity": "sha1-HKmIY8rfH6HYLITzXzHkBVagTzo=" + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=" + }, + "lodash._objecttypes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.3.0.tgz", + "integrity": "sha1-aj6jmH3W7rgCGy1cnDA1Scwrrh4=" + }, + "lodash._reinterpolate": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-2.3.0.tgz", + "integrity": "sha1-A+6dhcDlXL1ZDXFgiilb3aURKOw=" + }, + "lodash._renative": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._renative/-/lodash._renative-2.3.0.tgz", + "integrity": "sha1-d9jt1M7SbdWXH54Vpfdy5OMX+9M=" + }, + "lodash._reunescapedhtml": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._reunescapedhtml/-/lodash._reunescapedhtml-2.3.0.tgz", + "integrity": "sha1-25ILVax/P/glk5rOubosIxcT0k0=", + "requires": { + "lodash._htmlescapes": "2.3.0", + "lodash.keys": "2.3.0" + } + }, + "lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=" + }, + "lodash._setbinddata": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._setbinddata/-/lodash._setbinddata-2.3.0.tgz", + "integrity": "sha1-5WEEkKzRMnfVmFjZW18nJ/FQjwQ=", + "requires": { + "lodash._renative": "2.3.0", + "lodash.noop": "2.3.0" + } + }, + "lodash._shimkeys": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.3.0.tgz", + "integrity": "sha1-YR+TFJ4+bHIQlrSHae8pU3rai6k=", + "requires": { + "lodash._objecttypes": "2.3.0" + } + }, + "lodash._slice": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash._slice/-/lodash._slice-2.3.0.tgz", + "integrity": "sha1-FHGYEyhZly5GgMoppZkshVZpqlw=" + }, + "lodash.assign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz", + "integrity": "sha1-POnwI0tLIiPilrj6CsH+6OvKZPo=", + "requires": { + "lodash._baseassign": "3.2.0", + "lodash._createassigner": "3.1.1", + "lodash.keys": "3.1.2" + }, + "dependencies": { + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "requires": { + "lodash._getnative": "3.9.1", + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + } + } + }, + "lodash.assignin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", + "integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI=" + }, + "lodash.bind": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-2.3.0.tgz", + "integrity": "sha1-wqjhi2jl7MFS4rFoJmEW/qWwFsw=", + "requires": { + "lodash._createwrapper": "2.3.0", + "lodash._renative": "2.3.0", + "lodash._slice": "2.3.0" + } + }, + "lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=" + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "lodash.create": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", + "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "requires": { + "lodash._baseassign": "3.2.0", + "lodash._basecreate": "3.0.3", + "lodash._isiterateecall": "3.0.9" + }, + "dependencies": { + "lodash._basecreate": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=" + } + } + }, + "lodash.debounce": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-3.1.1.tgz", + "integrity": "sha1-gSIRw3ipTMKdWqTjNGzwv846ffU=", + "requires": { + "lodash._getnative": "3.9.1" + } + }, + "lodash.defaults": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-2.3.0.tgz", + "integrity": "sha1-qDKwAfE487uXIcKBmip8xa4h7SU=", + "requires": { + "lodash._objecttypes": "2.3.0", + "lodash.keys": "2.3.0" + } + }, + "lodash.defaultsdeep": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz", + "integrity": "sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E=" + }, + "lodash.escape": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-2.3.0.tgz", + "integrity": "sha1-hEw4xY+EThNi6+lnJhWbYs9fKlg=", + "requires": { + "lodash._escapehtmlchar": "2.3.0", + "lodash._reunescapedhtml": "2.3.0", + "lodash.keys": "2.3.0" + } + }, + "lodash.find": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz", + "integrity": "sha1-ywcE1Hq3F4n/oN6Ll92Sb7iLE7E=" + }, + "lodash.flatten": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-3.0.2.tgz", + "integrity": "sha1-3hz1d1j49EeTGdNcPpzGDEUBk4w=", + "requires": { + "lodash._baseflatten": "3.1.4", + "lodash._isiterateecall": "3.0.9" + } + }, + "lodash.foreach": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-2.3.0.tgz", + "integrity": "sha1-CDQEyR6EbudyRf3512UZxosq8Wg=", + "requires": { + "lodash._basecreatecallback": "2.3.0", + "lodash.forown": "2.3.0" + } + }, + "lodash.forown": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-2.3.0.tgz", + "integrity": "sha1-JPtKr4ANRfwtxgv+w84EyDajrX8=", + "requires": { + "lodash._basecreatecallback": "2.3.0", + "lodash._objecttypes": "2.3.0", + "lodash.keys": "2.3.0" + } + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.identity": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-2.3.0.tgz", + "integrity": "sha1-awGiEMlIU1XCqRO0i2cRIZoXPe0=" + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" + }, + "lodash.isfunction": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-2.3.0.tgz", + "integrity": "sha1-aylz5HpkfPEucNZ2rqE2Q3BuUmc=" + }, + "lodash.isobject": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.3.0.tgz", + "integrity": "sha1-LhbT/Fg9qYMZaJU/LY5tc0NPZ5k=", + "requires": { + "lodash._objecttypes": "2.3.0" + } + }, + "lodash.keys": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.3.0.tgz", + "integrity": "sha1-s1D0+Syqn0WkouzwGEVM8vKK4lM=", + "requires": { + "lodash._renative": "2.3.0", + "lodash._shimkeys": "2.3.0", + "lodash.isobject": "2.3.0" + } + }, + "lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=" + }, + "lodash.merge": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", + "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==" + }, + "lodash.mergewith": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", + "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==" + }, + "lodash.noop": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-2.3.0.tgz", + "integrity": "sha1-MFnWKNUbv5N80qC2/Dp/ISpmnCw=" + }, + "lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=" + }, + "lodash.restparam": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", + "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=" + }, + "lodash.support": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.3.0.tgz", + "integrity": "sha1-fq8DivTw1qq3drRKptz8gDNMm/0=", + "requires": { + "lodash._renative": "2.3.0" + } + }, + "lodash.template": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", + "integrity": "sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=", + "requires": { + "lodash._reinterpolate": "3.0.0", + "lodash.templatesettings": "4.1.0" + }, + "dependencies": { + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=" + }, + "lodash.templatesettings": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz", + "integrity": "sha1-K01OlbpEDZFf8IvImeRVNmZxMxY=", + "requires": { + "lodash._reinterpolate": "3.0.0" + } + } + } + }, + "lodash.templatesettings": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-2.3.0.tgz", + "integrity": "sha1-MD0TLDQnEAQNWhjvqi1XL9A/jNw=", + "requires": { + "lodash._reinterpolate": "2.3.0", + "lodash.escape": "2.3.0" + } + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + }, + "lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI=" + }, + "lodash.values": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-2.3.0.tgz", + "integrity": "sha1-ypb75gogsLDsK6K6X8anZb0Uo7o=", + "requires": { + "lodash.keys": "2.3.0" + } + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "requires": { + "chalk": "2.3.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "lolex": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.3.2.tgz", + "integrity": "sha512-A5pN2tkFj7H0dGIAM6MFvHKMJcPnjZsOMvR7ujCjfgW5TbV6H9vb1PgxLtHvjqNZTHsUolz+6/WEO0N1xNx2ng==" + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + }, + "loose-envify": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", + "requires": { + "js-tokens": "3.0.2" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "requires": { + "currently-unhandled": "0.4.1", + "signal-exit": "3.0.2" + } + }, + "lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha1-miyr0bno4K6ZOkv31YdcOcQujqw=", + "dev": true + }, + "lru-cache": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.2.tgz", + "integrity": "sha512-wgeVXhrDwAWnIF/yZARsFnMBtdFXOg1b8RIrhilp+0iDYN4mdQcNZElDZ0e4B64BhaxeQ5zN7PMyvu7we1kPeQ==", + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + }, + "make-dir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.2.0.tgz", + "integrity": "sha512-aNUAa4UMg/UougV25bbrU4ZaaKNjJ/3/xnvg/twpmKROPdKZPZ9wGgI0opdZzO8q/zUFawoUuixuOv33eZ61Iw==", + "requires": { + "pify": "3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" + } + } + }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "requires": { + "tmpl": "1.0.4" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=" + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "requires": { + "object-visit": "1.0.1" + } + }, + "markdown-it": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.1.tgz", + "integrity": "sha512-CzzqSSNkFRUf9vlWvhK1awpJreMRqdCrBvZ8DIoDWTOkESMIF741UPAhuAmbyWmdiFPA6WARNhnu2M6Nrhwa+A==", + "requires": { + "argparse": "1.0.10", + "entities": "1.1.1", + "linkify-it": "2.0.3", + "mdurl": "1.0.1", + "uc.micro": "1.0.5" + } + }, + "markdown-it-terminal": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/markdown-it-terminal/-/markdown-it-terminal-0.1.0.tgz", + "integrity": "sha1-VFq9jdAcPWI1O/zqcdtYC1HSK9k=", + "requires": { + "ansi-styles": "3.2.1", + "cardinal": "1.0.0", + "cli-table": "0.3.1", + "lodash.merge": "4.6.1", + "markdown-it": "8.4.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + } + } + }, + "matcher-collection": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/matcher-collection/-/matcher-collection-1.0.5.tgz", + "integrity": "sha512-nUCmzKipcJEwYsBVAFh5P+d7JBuhJaW1xs85Hara9xuMLqtCVUrW6DSC0JVIkluxEH2W45nPBM/wjHtBXa/tYA==", + "requires": { + "minimatch": "3.0.4" + } + }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "1.1.6" + } + }, + "md5-hex": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz", + "integrity": "sha1-0sSv6YPENwZiF5uMrRRSGRNQRsQ=", + "requires": { + "md5-o-matic": "0.1.1" + } + }, + "md5-o-matic": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/md5-o-matic/-/md5-o-matic-0.1.1.tgz", + "integrity": "sha1-givM1l4RfFFPqxdrJZRdVBAKA8M=" + }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "requires": { + "hash-base": "3.0.4", + "inherits": "2.0.3" + }, + "dependencies": { + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "requires": { + "inherits": "2.0.3", + "safe-buffer": "5.1.1" + } + } + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "memory-streams": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/memory-streams/-/memory-streams-0.1.3.tgz", + "integrity": "sha512-qVQ/CjkMyMInPaaRMrwWNDvf6boRZXaT/DbQeMYcCWuXPEBf1v8qChOc9OlEVQp2uOvRXa1Qu30fLmKhY6NipA==", + "requires": { + "readable-stream": "1.0.34" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + } + } + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "requires": { + "camelcase-keys": "2.1.0", + "decamelize": "1.2.0", + "loud-rejection": "1.6.0", + "map-obj": "1.0.1", + "minimist": "1.2.0", + "normalize-package-data": "2.4.0", + "object-assign": "4.1.1", + "read-pkg-up": "1.0.1", + "redent": "1.0.0", + "trim-newlines": "1.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, + "merge": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.0.tgz", + "integrity": "sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "merge-trees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-trees/-/merge-trees-1.0.1.tgz", + "integrity": "sha1-zL5nRWl4f53vF/1G5lJfVwC70j4=", + "requires": { + "can-symlink": "1.0.0", + "fs-tree-diff": "0.5.7", + "heimdalljs": "0.2.5", + "heimdalljs-logger": "0.1.9", + "rimraf": "2.6.2", + "symlink-or-copy": "1.2.0" + } + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", + "requires": { + "arr-diff": "2.0.0", + "array-unique": "0.2.1", + "braces": "1.8.5", + "expand-brackets": "0.1.5", + "extglob": "0.3.2", + "filename-regex": "2.0.1", + "is-extglob": "1.0.0", + "is-glob": "2.0.1", + "kind-of": "3.2.2", + "normalize-path": "2.1.1", + "object.omit": "2.0.1", + "parse-glob": "3.0.4", + "regex-cache": "0.4.4" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "requires": { + "bn.js": "4.11.8", + "brorand": "1.1.0" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "requires": { + "mime-db": "1.33.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + }, + "minimalistic-assert": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz", + "integrity": "sha1-cCvi3aazf0g2vLP121ZkG2Sh09M=" + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "1.1.11" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "requires": { + "for-in": "1.0.2", + "is-extendable": "1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "requires": { + "is-plain-object": "2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "mktemp": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/mktemp/-/mktemp-0.4.0.tgz", + "integrity": "sha1-bQUVYRyKjITkhKogABKbmOmB/ws=" + }, + "mocha": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.3.tgz", + "integrity": "sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==", + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.9.0", + "debug": "2.6.8", + "diff": "3.2.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.1", + "growl": "1.9.2", + "he": "1.1.1", + "json3": "3.3.2", + "lodash.create": "3.1.1", + "mkdirp": "0.5.1", + "supports-color": "3.1.2" + }, + "dependencies": { + "commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "requires": { + "graceful-readlink": "1.0.1" + } + }, + "debug": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", + "requires": { + "ms": "2.0.0" + } + }, + "diff": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=" + }, + "glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + }, + "supports-color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", + "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "requires": { + "has-flag": "1.0.0" + } + } + } + }, + "module-deps": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-4.1.1.tgz", + "integrity": "sha1-IyFYM/HaE/1gbMuAh7RIUty4If0=", + "requires": { + "JSONStream": "1.3.2", + "browser-resolve": "1.11.2", + "cached-path-relative": "1.0.1", + "concat-stream": "1.5.2", + "defined": "1.0.0", + "detective": "4.7.1", + "duplexer2": "0.1.4", + "inherits": "2.0.3", + "parents": "1.0.1", + "readable-stream": "2.3.5", + "resolve": "1.5.0", + "stream-combiner2": "1.1.1", + "subarg": "1.0.0", + "through2": "2.0.3", + "xtend": "4.0.1" + } + }, + "moo-server": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/moo-server/-/moo-server-1.3.0.tgz", + "integrity": "sha1-XceVaVZaENbv7VQ5SR5p0jkuWPE=" + }, + "morgan": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz", + "integrity": "sha1-0B+mxlhZt2/PMbPLU6OCGjEdgFE=", + "requires": { + "basic-auth": "2.0.0", + "debug": "2.6.9", + "depd": "1.1.2", + "on-finished": "2.3.0", + "on-headers": "1.0.1" + } + }, + "mout": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mout/-/mout-1.1.0.tgz", + "integrity": "sha512-XsP0vf4As6BfqglxZqbqQ8SR6KQot2AgxvR0gG+WtUkf90vUXchMOZQtPf/Hml1rEffJupqL/tIrU6EYhsUQjw==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "mustache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.0.tgz", + "integrity": "sha1-QCj3d4sXcIpImTCm5SrDvKDaQdA=" + }, + "mute-stream": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.6.tgz", + "integrity": "sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=" + }, + "nan": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.9.2.tgz", + "integrity": "sha512-ltW65co7f3PQWBDbqVvaU1WtFJUsNW7sWWm4HINhbMQIyVyzIeyZ8toX5TC5eeooE6piZoaEh4cZkueSKG3KYw==" + }, + "nanomatch": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.9.tgz", + "integrity": "sha512-n8R9bS8yQ6eSXaV6jHUpKzD8gLsin02w1HSFiegwrs9E098Ylhw5jdyKPaYqvHknHaSCKTPp7C8dGCQ0q9koXA==", + "requires": { + "arr-diff": "4.0.0", + "array-unique": "0.3.2", + "define-property": "2.0.2", + "extend-shallow": "3.0.2", + "fragment-cache": "0.2.1", + "is-odd": "2.0.0", + "is-windows": "1.0.2", + "kind-of": "6.0.2", + "object.pick": "1.3.0", + "regex-not": "1.0.2", + "snapdragon": "0.8.2", + "to-regex": "3.0.2" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=" + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + } + } + }, + "native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=" + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "nise": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.3.1.tgz", + "integrity": "sha512-kIH3X5YCj1vvj/32zDa9KNgzvfZd51ItGbiaCbtYhpnsCedLo0tIkb9zl169a41ATzF4z7kwMLz35XXDypma3g==", + "requires": { + "@sinonjs/formatio": "2.0.0", + "just-extend": "1.1.27", + "lolex": "2.3.2", + "path-to-regexp": "1.7.0", + "text-encoding": "0.6.4" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "dev": true, + "requires": { + "lower-case": "1.1.4" + } + }, + "node-fetch": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.1.tgz", + "integrity": "sha1-NpynC4L1DIZJYQSmx3bSdPTkotQ=" + }, + "node-gyp": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.6.2.tgz", + "integrity": "sha1-m/vlRWIoYoSDjnUOrAUpWFP6HGA=", + "requires": { + "fstream": "1.0.11", + "glob": "7.1.2", + "graceful-fs": "4.1.11", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "nopt": "3.0.6", + "npmlog": "4.1.2", + "osenv": "0.1.5", + "request": "2.79.0", + "rimraf": "2.6.2", + "semver": "5.3.0", + "tar": "2.2.1", + "which": "1.3.0" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=" + } + } + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=" + }, + "node-modules-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-modules-path/-/node-modules-path-1.0.1.tgz", + "integrity": "sha1-QAlrCM560OoUaAhjr0ScfHWl0cg=" + }, + "node-notifier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.2.1.tgz", + "integrity": "sha512-MIBs+AAd6dJ2SklbbE8RUDRlIVhU8MaNLh1A9SUZDUHPiZkWLFde6UNwG41yQHZEToHgJMXqyVZ9UcS/ReOVTg==", + "requires": { + "growly": "1.3.0", + "semver": "5.5.0", + "shellwords": "0.1.1", + "which": "1.3.0" + } + }, + "node-sass": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.7.2.tgz", + "integrity": "sha512-CaV+wLqZ7//Jdom5aUFCpGNoECd7BbNhjuwdsX/LkXBrHl8eb1Wjw4HvWqcFvhr5KuNgAk8i/myf/MQ1YYeroA==", + "requires": { + "async-foreach": "0.1.3", + "chalk": "1.1.3", + "cross-spawn": "3.0.1", + "gaze": "1.1.2", + "get-stdin": "4.0.1", + "glob": "7.1.2", + "in-publish": "2.0.0", + "lodash.assign": "4.2.0", + "lodash.clonedeep": "4.5.0", + "lodash.mergewith": "4.6.1", + "meow": "3.7.0", + "mkdirp": "0.5.1", + "nan": "2.9.2", + "node-gyp": "3.6.2", + "npmlog": "4.1.2", + "request": "2.79.0", + "sass-graph": "2.2.4", + "stdout-stream": "1.4.0", + "true-case-path": "1.0.2" + }, + "dependencies": { + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "requires": { + "lru-cache": "4.1.2", + "which": "1.3.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=" + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "requires": { + "abbrev": "1.1.1" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "requires": { + "hosted-git-info": "2.6.0", + "is-builtin-module": "1.0.0", + "semver": "5.5.0", + "validate-npm-package-license": "3.0.3" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "requires": { + "remove-trailing-separator": "1.1.0" + } + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" + }, + "npm-package-arg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.0.0.tgz", + "integrity": "sha512-hwC7g81KLgRmchv9ol6f3Fx4Yyc9ARX5X5niDHVILgpuvf08JRIgOZcEfpFXli3BgESoTrkauqorXm6UbvSgSg==", + "requires": { + "hosted-git-info": "2.6.0", + "osenv": "0.1.5", + "semver": "5.5.0", + "validate-npm-package-name": "3.0.0" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "requires": { + "path-key": "2.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "1.1.4", + "console-control-strings": "1.1.0", + "gauge": "2.7.4", + "set-blocking": "2.0.0" + } + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=" + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "requires": { + "copy-descriptor": "0.1.1", + "define-property": "0.2.5", + "kind-of": "3.2.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "3.2.2" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "3.2.2" + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", + "requires": { + "for-own": "0.1.5", + "is-extendable": "0.1.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1.0.2" + } + }, + "onetime": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "0.0.8", + "wordwrap": "0.0.3" + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "0.1.3", + "fast-levenshtein": "2.0.6", + "levn": "0.3.0", + "prelude-ls": "1.1.2", + "type-check": "0.3.2", + "wordwrap": "1.0.0" + }, + "dependencies": { + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + } + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" + }, + "ora": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-2.0.0.tgz", + "integrity": "sha512-g+IR0nMUXq1k4nE3gkENbN4wkF0XsVZFyxznTF6CdmwQ9qeTGONGpSR9LM5//1l0TVvJoJF3MkMtJp6slUsWFg==", + "requires": { + "chalk": "2.3.2", + "cli-cursor": "2.1.0", + "cli-spinners": "1.1.0", + "log-symbols": "2.2.0", + "strip-ansi": "4.0.0", + "wcwidth": "1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "requires": { + "restore-cursor": "2.0.0" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "requires": { + "mimic-fn": "1.2.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "requires": { + "onetime": "2.0.1", + "signal-exit": "3.0.2" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "3.0.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "os-browserify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz", + "integrity": "sha1-ScoCk+CxlZCl9d4Qx/JlphfY/lQ=" + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "requires": { + "lcid": "1.0.0" + } + }, + "os-shim": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz", + "integrity": "sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=" + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "output-file-sync": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/output-file-sync/-/output-file-sync-1.1.2.tgz", + "integrity": "sha1-0KM+7+YaIF+suQCS6CZZjVJFznY=", + "requires": { + "graceful-fs": "4.1.11", + "mkdirp": "0.5.1", + "object-assign": "4.1.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" + }, + "p-limit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.2.0.tgz", + "integrity": "sha512-Y/OtIaXtUPr4/YpMv1pCL5L5ed0rumAaAeBSj12F+bSlMdys7i8oQF/GUJmfpTS/QoaRrS/k6pma29haJpsMng==", + "requires": { + "p-try": "1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "requires": { + "p-limit": "1.2.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" + }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=" + }, + "parents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", + "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "requires": { + "path-platform": "0.11.15" + } + }, + "parse-asn1": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.0.tgz", + "integrity": "sha1-N8T5t+06tlx0gXtfJICTf7+XxxI=", + "requires": { + "asn1.js": "4.10.1", + "browserify-aes": "1.1.1", + "create-hash": "1.1.3", + "evp_bytestokey": "1.0.3", + "pbkdf2": "3.0.14" + } + }, + "parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", + "requires": { + "glob-base": "0.3.0", + "is-dotfile": "1.0.3", + "is-extglob": "1.0.0", + "is-glob": "2.0.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "requires": { + "error-ex": "1.3.1" + } + }, + "parsejson": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz", + "integrity": "sha1-q343WfIJ7OmUN5c/fQ8fZK4OZKs=", + "requires": { + "better-assert": "1.0.2" + } + }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "requires": { + "better-assert": "1.0.2" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "requires": { + "better-assert": "1.0.2" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" + }, + "passwd-user": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/passwd-user/-/passwd-user-1.2.1.tgz", + "integrity": "sha1-oBpdxjnvAH3FY2S4F4VpCArTp7g=", + "requires": { + "exec-file-sync": "2.0.2" + } + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=" + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "requires": { + "pinkie-promise": "2.0.1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" + }, + "path-platform": { + "version": "0.11.15", + "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", + "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=" + }, + "path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha1-BrJhE/Vr6rBCVFojv6iAA8ysJg8=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "requires": { + "graceful-fs": "4.1.11", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + } + }, + "pbkdf2": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.14.tgz", + "integrity": "sha512-gjsZW9O34fm0R7PaLHRJmLLVfSoesxztjPjE9o6R+qtVJij90ltg1joIovN9GKrRW3t1PzhDDG3UMEMFfZ+1wA==", + "requires": { + "create-hash": "1.1.3", + "create-hmac": "1.1.6", + "ripemd160": "2.0.1", + "safe-buffer": "5.1.1", + "sha.js": "2.4.10" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "http://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "2.0.4" + } + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==", + "dev": true + }, + "portfinder": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.13.tgz", + "integrity": "sha1-uzLs2HwnEErm7kS1o8y/Drsa7ek=", + "requires": { + "async": "1.5.2", + "debug": "2.6.9", + "mkdirp": "0.5.1" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=" + }, + "postcss": { + "version": "6.0.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.19.tgz", + "integrity": "sha512-f13HRz0HtVwVaEuW6J6cOUCBLFtymhgyLPV7t4QEk2UD3twRI9IluDcQNdzQdBpiixkXj2OmzejhhTbSbDxNTg==", + "requires": { + "chalk": "2.3.2", + "source-map": "0.6.1", + "supports-color": "5.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "postcss-less": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-1.1.3.tgz", + "integrity": "sha512-WS0wsQxRm+kmN8wEYAGZ3t4lnoNfoyx9EJZrhiPR1K0lMHR0UNWnz52Ya5QRXChHtY75Ef+kDc05FpnBujebgw==", + "requires": { + "postcss": "5.2.18" + }, + "dependencies": { + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + }, + "postcss": { + "version": "5.2.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz", + "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==", + "requires": { + "chalk": "1.1.3", + "js-base64": "2.4.3", + "source-map": "0.5.7", + "supports-color": "3.2.3" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "requires": { + "has-flag": "1.0.0" + } + } + } + }, + "postcss-scss": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-1.0.4.tgz", + "integrity": "sha512-IFj42Hz2cBHHFvZTqkJqU08JCCM/MZU5/uNkTUZBaBFP2d4C5unw4HyCL52RfCwJb6KoVUD3eoepxMh1dfBFCQ==", + "requires": { + "postcss": "6.0.19" + } + }, + "postcss-selector-namespace": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/postcss-selector-namespace/-/postcss-selector-namespace-1.5.0.tgz", + "integrity": "sha512-tQmqzuqOyH4sCwr+yRmnIKPzNQy1tIVvEZMR9qd82npDJ2X5KwMoeFFjCdupJ00eqMrCYj58wWvSwwZDA3kTmA==", + "requires": { + "postcss": "6.0.19" + } + }, + "postcss-value-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz", + "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU=" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=" + }, + "prettier": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.11.1.tgz", + "integrity": "sha512-T/KD65Ot0PB97xTrG8afQ46x3oiVhnfGjGESSI9NWYcG92+OUPZKkwHqGWXH2t9jK1crnQjubECW0FuOth+hxw==", + "dev": true + }, + "printf": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/printf/-/printf-0.2.5.tgz", + "integrity": "sha1-xDjKLKM+OSdnHbSracDlL5NqTw8=" + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==" + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "process-relative-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-relative-require/-/process-relative-require-1.0.0.tgz", + "integrity": "sha1-FZDfz1uPKYO6U+OYRGtoJAtMxoo=", + "requires": { + "node-modules-path": "1.0.1" + } + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=", + "dev": true + }, + "promise-map-series": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/promise-map-series/-/promise-map-series-0.2.3.tgz", + "integrity": "sha1-wtN3r8kyU/a9A9u3d1XriKsgqEc=", + "requires": { + "rsvp": "3.6.2" + } + }, + "promised-io": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/promised-io/-/promised-io-0.3.5.tgz", + "integrity": "sha1-StIXuzZYvKrplGsXqGaOzYUeE1Y=" + }, + "proxy-addr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.3.tgz", + "integrity": "sha512-jQTChiCJteusULxjBp8+jftSQE5Obdl3k4cnmLA6WXtK6XFuWRnvVL7aCiBqaLPM8c4ph0S4tKna8XvmIwEnXQ==", + "requires": { + "forwarded": "0.1.2", + "ipaddr.js": "1.6.0" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, + "public-encrypt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.0.tgz", + "integrity": "sha1-OfaZ86RlYN1eusvKaTyvfGXBjMY=", + "requires": { + "bn.js": "4.11.8", + "browserify-rsa": "4.0.1", + "create-hash": "1.1.3", + "parse-asn1": "5.1.0", + "randombytes": "2.0.6" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=" + }, + "quick-temp": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/quick-temp/-/quick-temp-0.1.8.tgz", + "integrity": "sha1-urAqJCq4+w3XWKPJd2sy+aXZRAg=", + "requires": { + "mktemp": "0.4.0", + "rimraf": "2.6.2", + "underscore.string": "3.3.4" + } + }, + "randomatic": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz", + "integrity": "sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how==", + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "requires": { + "randombytes": "2.0.6", + "safe-buffer": "5.1.1" + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raven-js": { + "version": "3.23.3", + "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.23.3.tgz", + "integrity": "sha512-x7ZW/VDX+J+kT7sOlpjkc/JTqu43VVUbS17gbQ9m8HHN6xdV7dzbNedCBkwsn1G3Y9iqvQgqrE3OKX3b8SSl8A==" + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + } + }, + "read-only-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", + "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", + "requires": { + "readable-stream": "2.3.5" + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "requires": { + "load-json-file": "1.1.0", + "normalize-package-data": "2.4.0", + "path-type": "1.1.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "requires": { + "find-up": "1.1.2", + "read-pkg": "1.1.0" + } + }, + "readable-stream": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz", + "integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + }, + "dependencies": { + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "recast": { + "version": "0.10.33", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.10.33.tgz", + "integrity": "sha1-lCgI96oBbx+nFCxGHX5XBKqo1pc=", + "requires": { + "ast-types": "0.8.12", + "esprima-fb": "15001.1001.0-dev-harmony-fb", + "private": "0.1.8", + "source-map": "0.5.7" + }, + "dependencies": { + "ast-types": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.8.12.tgz", + "integrity": "sha1-oNkOQ1G7iHcWyD/WN+v4GK9K38w=" + }, + "esprima-fb": { + "version": "15001.1001.0-dev-harmony-fb", + "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz", + "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk=" + } + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "requires": { + "indent-string": "2.1.0", + "strip-indent": "1.0.1" + } + }, + "redeyed": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-1.0.1.tgz", + "integrity": "sha1-6WwZO0DAgWsArshCaY5hGF5VSYo=", + "requires": { + "esprima": "3.0.0" + }, + "dependencies": { + "esprima": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.0.0.tgz", + "integrity": "sha1-U88kes2ncxPlUcOqLnM0LT+099k=" + } + } + }, + "regenerate": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz", + "integrity": "sha512-jVpo1GadrDAK59t/0jRx5VxYWQEDkkEKi6+HjE3joFVLfDOh9Xrdh0dF1eSq+BI/SwvTQ44gSscJ8N5zYL61sg==" + }, + "regenerator": { + "version": "0.8.40", + "resolved": "https://registry.npmjs.org/regenerator/-/regenerator-0.8.40.tgz", + "integrity": "sha1-oORXxY69uuV1yfjNdRJ+k3VkNdg=", + "requires": { + "commoner": "0.10.8", + "defs": "1.1.1", + "esprima-fb": "15001.1001.0-dev-harmony-fb", + "private": "0.1.8", + "recast": "0.10.33", + "through": "2.3.8" + }, + "dependencies": { + "esprima-fb": { + "version": "15001.1001.0-dev-harmony-fb", + "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz", + "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk=" + } + } + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "private": "0.1.8" + } + }, + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "requires": { + "is-equal-shallow": "0.1.3" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "requires": { + "extend-shallow": "3.0.2", + "safe-regex": "1.1.0" + } + }, + "regexpp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.0.1.tgz", + "integrity": "sha512-8Ph721maXiOYSLtaDGKVmDn5wdsNaF6Px85qFNeMPQq0r8K5Y10tgP6YuR65Ws35n4DvzFcCxEnRNBIXQunzLw==", + "dev": true + }, + "regexpu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/regexpu/-/regexpu-1.3.0.tgz", + "integrity": "sha1-5TTcmRqeWEYFDJjebX3UpVyeoW0=", + "requires": { + "esprima": "2.7.3", + "recast": "0.10.33", + "regenerate": "1.3.3", + "regjsgen": "0.2.0", + "regjsparser": "0.1.5" + }, + "dependencies": { + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=" + } + } + }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "requires": { + "regenerate": "1.3.3", + "regjsgen": "0.2.0", + "regjsparser": "0.1.5" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=" + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "requires": { + "jsesc": "0.5.0" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=" + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "requires": { + "is-finite": "1.0.2" + } + }, + "request": { + "version": "2.79.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", + "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", + "requires": { + "aws-sign2": "0.6.0", + "aws4": "1.6.0", + "caseless": "0.11.0", + "combined-stream": "1.0.6", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.1.4", + "har-validator": "2.0.6", + "hawk": "3.1.3", + "http-signature": "1.1.1", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.18", + "oauth-sign": "0.8.2", + "qs": "6.3.2", + "stringstream": "0.0.5", + "tough-cookie": "2.3.4", + "tunnel-agent": "0.4.3", + "uuid": "3.2.1" + }, + "dependencies": { + "qs": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.2.tgz", + "integrity": "sha1-51vV9uJoEioqDgvaYwslUMFmUCw=" + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, + "require-folder-tree": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/require-folder-tree/-/require-folder-tree-1.4.5.tgz", + "integrity": "sha1-3+VTy6uYzIjhxWo/LzWPBu+FvLA=", + "dev": true, + "requires": { + "lodash": "3.8.0" + }, + "dependencies": { + "lodash": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz", + "integrity": "sha1-N265i9zZOCqTZcM8TLglDeEyW5E=", + "dev": true + } + } + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "0.1.0", + "resolve-from": "1.0.1" + } + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, + "resolve": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz", + "integrity": "sha512-hgoSGrc3pjzAPHNBg+KnFcK2HwlHTs/YrAGUr6qgTVUZmXv1UEXXl0bZNBKMA9fud6lRYFdPGz0xXxycPzmmiw==", + "requires": { + "path-parse": "1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "requires": { + "exit-hook": "1.1.1", + "onetime": "1.1.0" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "requires": { + "align-text": "0.1.4" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "requires": { + "glob": "7.1.2" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "ripemd160": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", + "integrity": "sha1-D0WEKVxTo2KK9+bXmsohzlfRxuc=", + "requires": { + "hash-base": "2.0.2", + "inherits": "2.0.3" + } + }, + "rsvp": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.6.2.tgz", + "integrity": "sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw==" + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "requires": { + "is-promise": "2.1.0" + } + }, + "rx": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz", + "integrity": "sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=" + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "dev": true + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "dev": true, + "requires": { + "rx-lite": "4.0.8" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "safe-json-parse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz", + "integrity": "sha1-PnZyPjjf3aE8mx0poeB//uSzC1c=" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "requires": { + "ret": "0.1.15" + } + }, + "samsam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==" + }, + "sane": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/sane/-/sane-2.4.1.tgz", + "integrity": "sha512-fW9svvNd81XzHDZyis9/tEY1bZikDGryy8Hi1BErPyNPYv47CdLseUN+tI5FBHWXEENRtj1SWtX/jBnggLaP0w==", + "requires": { + "anymatch": "1.3.2", + "exec-sh": "0.2.1", + "fb-watchman": "2.0.0", + "fsevents": "1.1.3", + "minimatch": "3.0.4", + "minimist": "1.2.0", + "walker": "1.0.7", + "watch": "0.18.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, + "sass-graph": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", + "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", + "requires": { + "glob": "7.1.2", + "lodash": "4.17.5", + "scss-tokenizer": "0.2.3", + "yargs": "7.1.0" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "yargs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "requires": { + "camelcase": "3.0.0", + "cliui": "3.2.0", + "decamelize": "1.2.0", + "get-caller-file": "1.0.2", + "os-locale": "1.4.0", + "read-pkg-up": "1.0.1", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "1.0.2", + "which-module": "1.0.0", + "y18n": "3.2.1", + "yargs-parser": "5.0.0" + } + }, + "yargs-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "requires": { + "camelcase": "3.0.0" + } + } + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "requires": { + "js-base64": "2.4.3", + "source-map": "0.4.4" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==" + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "1.1.2", + "destroy": "1.0.4", + "encodeurl": "1.0.2", + "escape-html": "1.0.3", + "etag": "1.8.1", + "fresh": "0.5.2", + "http-errors": "1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "2.3.0", + "range-parser": "1.2.0", + "statuses": "1.4.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "1.0.2", + "escape-html": "1.0.3", + "parseurl": "1.3.2", + "send": "0.16.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "requires": { + "extend-shallow": "2.0.1", + "is-extendable": "0.1.1", + "is-plain-object": "2.0.4", + "split-string": "3.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + } + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "sha.js": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.10.tgz", + "integrity": "sha512-vnwmrFDlOExK4Nm16J2KMWHLrp14lBrjxMxBJpu++EnsuBmpiYaM/MEs46Vxxm/4FvdP5yTwuCTO9it5FSjrqA==", + "requires": { + "inherits": "2.0.3", + "safe-buffer": "5.1.1" + } + }, + "shasum": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", + "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "requires": { + "json-stable-stringify": "0.0.1", + "sha.js": "2.4.10" + }, + "dependencies": { + "json-stable-stringify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", + "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", + "requires": { + "jsonify": "0.0.0" + } + } + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "requires": { + "shebang-regex": "1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "requires": { + "array-filter": "0.0.1", + "array-map": "0.0.0", + "array-reduce": "0.0.0", + "jsonify": "0.0.0" + } + }, + "shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==" + }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=" + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "silent-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/silent-error/-/silent-error-1.1.0.tgz", + "integrity": "sha1-IglwbxyFCp8dENDYQJGLRvJuG8k=", + "requires": { + "debug": "2.6.9" + } + }, + "simple-fmt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/simple-fmt/-/simple-fmt-0.1.0.tgz", + "integrity": "sha1-GRv1ZqWeZTBILLJatTtKjchcOms=" + }, + "simple-is": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/simple-is/-/simple-is-0.2.0.tgz", + "integrity": "sha1-Krt1qt453rXMgVzhDmGRFkhQuvA=" + }, + "sinon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-3.3.0.tgz", + "integrity": "sha512-/flfGfIxIRXSvZBHJzIf3iAyGYkmMQq6SQjA0cx9SOuVuq+4ZPPO4LJtH1Ce0Lznax1KSG1U6Dad85wIcSW19w==", + "requires": { + "build": "0.1.4", + "diff": "3.5.0", + "formatio": "1.2.0", + "lodash.get": "4.4.2", + "lolex": "2.3.2", + "native-promise-only": "0.8.1", + "nise": "1.3.1", + "path-to-regexp": "1.7.0", + "samsam": "1.3.0", + "text-encoding": "0.6.4", + "type-detect": "4.0.8" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "requires": { + "isarray": "0.0.1" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + } + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + } + } + }, + "snake-case": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-2.1.0.tgz", + "integrity": "sha1-Qb2xtz8w7GagTU4srRt2OH1NbZ8=", + "dev": true, + "requires": { + "no-case": "2.3.2" + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "requires": { + "base": "0.11.2", + "debug": "2.6.9", + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "map-cache": "0.2.2", + "source-map": "0.5.7", + "source-map-resolve": "0.5.1", + "use": "3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "0.1.6" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "requires": { + "define-property": "1.0.0", + "isobject": "3.0.1", + "snapdragon-util": "3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "1.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "requires": { + "kind-of": "3.2.2" + } + }, + "sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", + "requires": { + "hoek": "2.16.3" + } + }, + "socket.io": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.6.0.tgz", + "integrity": "sha1-PkDZMmN+a9kjmBslyvfFPoO24uE=", + "requires": { + "debug": "2.3.3", + "engine.io": "1.8.0", + "has-binary": "0.1.7", + "object-assign": "4.1.0", + "socket.io-adapter": "0.5.0", + "socket.io-client": "1.6.0", + "socket.io-parser": "2.3.1" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + }, + "object-assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=" + } + } + }, + "socket.io-adapter": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz", + "integrity": "sha1-y21LuL7IHhB4uZZ3+c7QBGBmu4s=", + "requires": { + "debug": "2.3.3", + "socket.io-parser": "2.3.1" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + } + } + }, + "socket.io-client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.6.0.tgz", + "integrity": "sha1-W2aPT3cTBN/u0XkGRwg4b6ZxeFM=", + "requires": { + "backo2": "1.0.2", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "2.3.3", + "engine.io-client": "1.8.0", + "has-binary": "0.1.7", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseuri": "0.0.5", + "socket.io-parser": "2.3.1", + "to-array": "0.1.4" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + } + } + }, + "socket.io-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz", + "integrity": "sha1-3VMgJRA85Clpcya+/WQAX8/ltKA=", + "requires": { + "component-emitter": "1.1.2", + "debug": "2.2.0", + "isarray": "0.0.1", + "json3": "3.3.2" + }, + "dependencies": { + "component-emitter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz", + "integrity": "sha1-KWWU8nU9qmOZbSrwjRWpURbJrsM=" + }, + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + } + } + }, + "sort-object-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.2.tgz", + "integrity": "sha1-06bEjcKsl+a8lDZ2luA/bQnTeVI=" + }, + "sort-package-json": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-1.11.0.tgz", + "integrity": "sha1-t7Wevfrz+HGewLwgViZOk3hoy/s=", + "requires": { + "sort-object-keys": "1.1.2" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "source-map-resolve": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.1.tgz", + "integrity": "sha512-0KW2wvzfxm8NCTb30z0LMNyPqWCdDGE2viwzUaucqJdkTRXtZiSY3I+2A6nVAjmdOy0I4gU8DwnVVGsk9jvP2A==", + "requires": { + "atob": "2.0.3", + "decode-uri-component": "0.2.0", + "resolve-url": "0.2.1", + "source-map-url": "0.4.0", + "urix": "0.1.0" + }, + "dependencies": { + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" + } + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "requires": { + "source-map": "0.5.7" + } + }, + "source-map-url": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.3.0.tgz", + "integrity": "sha1-fsrxO1e80J2opAxdJp2zN5nUqvk=" + }, + "sourcemap-validator": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/sourcemap-validator/-/sourcemap-validator-1.0.7.tgz", + "integrity": "sha512-9fMR0sfBJ5wWjuMvqe6jCyyw8cT5/dCXtvxPtWnUKgBxqrpy9TlqGmCzsgTZoDhalgcmAM4zlEOQJWaxRzSzTA==", + "requires": { + "jsesc": "0.3.0", + "lodash.foreach": "2.3.0", + "lodash.template": "2.3.0", + "source-map": "0.1.43" + }, + "dependencies": { + "jsesc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.3.0.tgz", + "integrity": "sha1-G/XuY7RTn+LibQwemcJAuXpFeXI=" + }, + "lodash.template": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-2.3.0.tgz", + "integrity": "sha1-Tj4pxDO0z+pnXsg15vEjkcYf0is=", + "requires": { + "lodash._escapestringchar": "2.3.0", + "lodash._reinterpolate": "2.3.0", + "lodash.defaults": "2.3.0", + "lodash.escape": "2.3.0", + "lodash.keys": "2.3.0", + "lodash.templatesettings": "2.3.0", + "lodash.values": "2.3.0" + } + }, + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "spawn-args": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/spawn-args/-/spawn-args-0.2.0.tgz", + "integrity": "sha1-+30L0dcP1DFr2ePew4nmX51jYbs=" + }, + "spawn-sync": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", + "integrity": "sha1-sAeZVX63+wyDdsKdROih6mfldHY=", + "requires": { + "concat-stream": "1.5.2", + "os-shim": "0.1.3" + } + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "requires": { + "spdx-expression-parse": "3.0.0", + "spdx-license-ids": "3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==" + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "requires": { + "spdx-exceptions": "2.1.0", + "spdx-license-ids": "3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-2+EPwgbnmOIl8HjGBXXMd9NAu02vLjOO1nWw4kmeRDFyHn+M/ETfHxQUK0oXg8ctgVnl9t3rosNVsZ1jG61nDA==" + }, + "spin.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/spin.js/-/spin.js-2.3.2.tgz", + "integrity": "sha1-bKpW1SBnNFD9XPvGlx5tB3LDeho=" + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "requires": { + "extend-shallow": "3.0.2" + } + }, + "sprintf-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz", + "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" + }, + "sshpk": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.1.tgz", + "integrity": "sha1-Ew9Zde3a2WPx1W+SuaxsUfqfg+s=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "stable": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.6.tgz", + "integrity": "sha1-kQ9dKu17Ugxud3SZwfMuE5/eyxA=" + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "requires": { + "define-property": "0.2.5", + "object-copy": "0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, + "stdout-stream": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.0.tgz", + "integrity": "sha1-osfIWH5U2UJ+qe2zrD8s1SLfN4s=", + "requires": { + "readable-stream": "2.3.5" + } + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.5" + } + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "requires": { + "duplexer2": "0.1.4", + "readable-stream": "2.3.5" + } + }, + "stream-http": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.1.tgz", + "integrity": "sha512-cQ0jo17BLca2r0GfRdZKYAGLU6JRoIWxqSOakUMuKOT6MOK7AAlE856L33QuDmAy/eeOrhLee3dZKX0Uadu93A==", + "requires": { + "builtin-status-codes": "3.0.0", + "inherits": "2.0.3", + "readable-stream": "2.3.5", + "to-arraybuffer": "1.0.1", + "xtend": "4.0.1" + } + }, + "stream-splicer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.0.tgz", + "integrity": "sha1-G2O+Q4oTPktnHMGTUZdgAXWRDYM=", + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.5" + } + }, + "string-template": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", + "integrity": "sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=" + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "stringmap": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stringmap/-/stringmap-0.2.2.tgz", + "integrity": "sha1-VWwTeyWPlCuHdvWy71gqoGnX0bE=" + }, + "stringset": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/stringset/-/stringset-0.2.1.tgz", + "integrity": "sha1-7yWcTjSTRDd/zRyRPdLoSMnAQrU=" + }, + "stringstream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "requires": { + "is-utf8": "0.2.1" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "requires": { + "get-stdin": "4.0.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "styled_string": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/styled_string/-/styled_string-0.0.1.tgz", + "integrity": "sha1-0ieCvYEpVFm8Tx3xjEutjpTdEko=" + }, + "subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "requires": { + "minimist": "1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, + "sum-up": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sum-up/-/sum-up-1.0.3.tgz", + "integrity": "sha1-HGYfZnBX9jvLeHWqFDi8FiUlFW4=", + "requires": { + "chalk": "1.1.3" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + }, + "svgo": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.6.6.tgz", + "integrity": "sha1-s0CIkDbyD5tEdUMHfQ9Vc+0ETAg=", + "requires": { + "coa": "1.0.4", + "colors": "1.1.2", + "csso": "2.0.0", + "js-yaml": "3.6.1", + "mkdirp": "0.5.1", + "sax": "1.2.4", + "whet.extend": "0.9.9" + }, + "dependencies": { + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=" + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=" + }, + "js-yaml": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz", + "integrity": "sha1-bl/mfYsgXOTSL60Ft3geja3MSzA=", + "requires": { + "argparse": "1.0.10", + "esprima": "2.7.3" + } + } + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, + "symlink-or-copy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.2.0.tgz", + "integrity": "sha512-W31+GLiBmU/ZR02Ii0mVZICuNEN9daZ63xZMPDsYgPgNjMtg+atqLEGI7PPI936jYSQZxoLb/63xos8Adrx4Eg==" + }, + "syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "requires": { + "acorn-node": "1.3.0" + } + }, + "table": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz", + "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==", + "dev": true, + "requires": { + "ajv": "5.5.2", + "ajv-keywords": "2.1.1", + "chalk": "2.3.2", + "lodash": "4.17.5", + "slice-ansi": "1.0.0", + "string-width": "2.1.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "dev": true, + "requires": { + "ansi-styles": "3.2.1", + "escape-string-regexp": "1.0.5", + "supports-color": "5.3.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + }, + "supports-color": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz", + "integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "tap-parser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-5.4.0.tgz", + "integrity": "sha512-BIsIaGqv7uTQgTW1KLTMNPSEQf4zDDPgYOBRdgOfuB+JFOLRBfEu6cLa/KvMvmqggu1FKXDfitjLwsq4827RvA==", + "requires": { + "events-to-array": "1.1.2", + "js-yaml": "3.11.0", + "readable-stream": "2.3.5" + } + }, + "tar": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "requires": { + "block-stream": "0.0.9", + "fstream": "1.0.11", + "inherits": "2.0.3" + } + }, + "temp": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz", + "integrity": "sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=", + "requires": { + "os-tmpdir": "1.0.2", + "rimraf": "2.2.8" + }, + "dependencies": { + "rimraf": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz", + "integrity": "sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=" + } + } + }, + "testem": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/testem/-/testem-2.0.0.tgz", + "integrity": "sha1-sFyWIAx6yYuumY1xyUwMU0WQfRM=", + "requires": { + "backbone": "1.3.3", + "bluebird": "3.5.1", + "charm": "1.0.2", + "commander": "2.8.1", + "consolidate": "0.14.5", + "execa": "0.9.0", + "express": "4.16.3", + "fireworm": "0.7.1", + "glob": "7.1.2", + "http-proxy": "1.16.2", + "js-yaml": "3.11.0", + "lodash.assignin": "4.2.0", + "lodash.castarray": "4.4.0", + "lodash.clonedeep": "4.5.0", + "lodash.find": "4.6.0", + "lodash.uniqby": "4.7.0", + "mkdirp": "0.5.1", + "mustache": "2.3.0", + "node-notifier": "5.2.1", + "npmlog": "4.1.2", + "printf": "0.2.5", + "rimraf": "2.6.2", + "socket.io": "1.6.0", + "spawn-args": "0.2.0", + "styled_string": "0.0.1", + "tap-parser": "5.4.0", + "xmldom": "0.1.27" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "textextensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-2.2.0.tgz", + "integrity": "sha512-j5EMxnryTvKxwH2Cq+Pb43tsf6sdEgw6Pdwxk83mPaq0ToeFJt6WE4J3s5BqY7vmjlLgkgXvhtXUxo80FyBhCA==" + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "requires": { + "readable-stream": "2.3.5", + "xtend": "4.0.1" + } + }, + "timers-browserify": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", + "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", + "requires": { + "process": "0.11.10" + } + }, + "timespan": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/timespan/-/timespan-2.3.0.tgz", + "integrity": "sha1-SQLOBAvRPYRcj1myfp1ZutbzmSk=" + }, + "tiny-lr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz", + "integrity": "sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA==", + "requires": { + "body": "5.1.0", + "debug": "3.1.0", + "faye-websocket": "0.10.0", + "livereload-js": "2.3.0", + "object-assign": "4.1.1", + "qs": "6.5.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "tmp": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.28.tgz", + "integrity": "sha1-Fyc1t/YU6nrzlmT6hM8N5OUV0SA=", + "requires": { + "os-tmpdir": "1.0.2" + } + }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=" + }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=" + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=" + }, + "to-iso-string": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/to-iso-string/-/to-iso-string-0.0.2.tgz", + "integrity": "sha1-TcGeZk38y+Jb2NtQiwDG2hWCVdE=" + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "requires": { + "kind-of": "3.2.2" + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "requires": { + "define-property": "2.0.2", + "extend-shallow": "3.0.2", + "regex-not": "1.0.2", + "safe-regex": "1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "requires": { + "is-number": "3.0.0", + "repeat-string": "1.6.1" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "3.2.2" + } + } + } + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "requires": { + "punycode": "1.4.1" + } + }, + "tree-sync": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-sync/-/tree-sync-1.2.2.tgz", + "integrity": "sha1-LPdrhYn1n/7bWNtaOsfLAT0BWLc=", + "requires": { + "debug": "2.6.9", + "fs-tree-diff": "0.5.7", + "mkdirp": "0.5.1", + "quick-temp": "0.1.8", + "walk-sync": "0.2.7" + }, + "dependencies": { + "walk-sync": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-0.2.7.tgz", + "integrity": "sha1-tJvk7mhnZXrrc2l4tWop0Q+jmWk=", + "requires": { + "ensure-posix-path": "1.0.2", + "matcher-collection": "1.0.5" + } + } + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=" + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=" + }, + "true-case-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.2.tgz", + "integrity": "sha1-fskRMJJHZsf1c74wIMNPj9/QDWI=", + "requires": { + "glob": "6.0.4" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "try-resolve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/try-resolve/-/try-resolve-1.0.1.tgz", + "integrity": "sha1-z95vq9ctY+V5fPqrhzq76OcA6RI=" + }, + "tryor": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tryor/-/tryor-0.1.2.tgz", + "integrity": "sha1-gUXkynyv9ArN48z5Rui4u3W0Fys=" + }, + "tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=" + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2" + } + }, + "type-detect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", + "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=" + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "uc.micro": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", + "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==" + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "optional": true, + "requires": { + "source-map": "0.5.7", + "uglify-to-browserify": "1.0.2", + "yargs": "3.10.0" + }, + "dependencies": { + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "optional": true + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "optional": true, + "requires": { + "center-align": "0.1.3", + "right-align": "0.1.3", + "wordwrap": "0.0.2" + } + }, + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "optional": true + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "optional": true, + "requires": { + "camelcase": "1.2.1", + "cliui": "2.1.0", + "decamelize": "1.2.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "optional": true + }, + "ultron": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", + "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=" + }, + "umd": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", + "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==" + }, + "underscore": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" + }, + "underscore.string": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.4.tgz", + "integrity": "sha1-LCo/n4PmR2L9xF5s6sZRQoZCE9s=", + "requires": { + "sprintf-js": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "requires": { + "arr-union": "3.1.0", + "get-value": "2.0.6", + "is-extendable": "0.1.1", + "set-value": "0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "0.1.1" + } + }, + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "requires": { + "extend-shallow": "2.0.1", + "is-extendable": "0.1.1", + "is-plain-object": "2.0.4", + "to-object-path": "0.3.0" + } + } + } + }, + "unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "requires": { + "crypto-random-string": "1.0.0" + } + }, + "universalify": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "requires": { + "has-value": "0.3.1", + "isobject": "3.0.1" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "requires": { + "get-value": "2.0.6", + "has-values": "0.1.4", + "isobject": "2.1.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=" + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, + "untildify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz", + "integrity": "sha1-F+soB5h/dpUunASF/DEdBqgmouA=", + "requires": { + "os-homedir": "1.0.2" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "use": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.0.tgz", + "integrity": "sha512-6UJEQM/L+mzC3ZJNM56Q4DFGLX/evKGRg15UJHGB9X5j5Z3AFbgZvjUh2yq/UJUY4U5dh7Fal++XbNg1uzpRAw==", + "requires": { + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" + } + } + }, + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=" + }, + "user-info": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/user-info/-/user-info-1.0.0.tgz", + "integrity": "sha1-gcgrftY+Z0wkdWZ2U0E7PHb94jk=", + "requires": { + "os-homedir": "1.0.2", + "passwd-user": "1.2.1", + "username": "1.0.1" + } + }, + "username": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/username/-/username-1.0.1.tgz", + "integrity": "sha1-4fcilePljgbwAsYyfOBol6mc1n8=", + "requires": { + "meow": "3.7.0" + } + }, + "username-sync": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/username-sync/-/username-sync-1.0.1.tgz", + "integrity": "sha1-HN6H7vz5S4gimE2Ti6K3l0Jtrh8=" + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "requires": { + "inherits": "2.0.1" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=" + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" + }, + "validate-npm-package-license": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz", + "integrity": "sha512-63ZOUnL4SIXj4L0NixR3L1lcjO38crAbgrTpl28t8jjrfuiOBL5Iygm+60qPs/KsZGzPNg6Smnc/oY16QTjF0g==", + "requires": { + "spdx-correct": "3.0.0", + "spdx-expression-parse": "3.0.0" + } + }, + "validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha1-X6kS2B630MdK/BQN5zF/DKffQ34=", + "requires": { + "builtins": "1.0.3" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "1.3.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + } + } + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "requires": { + "indexof": "0.0.1" + } + }, + "walk-sync": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-0.3.2.tgz", + "integrity": "sha512-FMB5VqpLqOCcqrzA9okZFc0wq0Qbmdm396qJxvQZhDpyu0W95G9JCmp74tx7iyYnyOcBtUuKJsgIKAqjozvmmQ==", + "requires": { + "ensure-posix-path": "1.0.2", + "matcher-collection": "1.0.5" + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "requires": { + "makeerror": "1.0.11" + } + }, + "watch": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/watch/-/watch-0.18.0.tgz", + "integrity": "sha1-KAlUdsbffJDJYxOJkMClQj60uYY=", + "requires": { + "exec-sh": "0.2.1", + "minimist": "1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, + "watch-detector": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/watch-detector/-/watch-detector-0.1.0.tgz", + "integrity": "sha512-vfzMMfpjQc88xjETwl2HuE6PjEuxCBeyC4bQmqrHrofdfYWi/4mEJklYbNgSzpqM9PxubsiPIrE5SZ1FDyiQ2w==", + "requires": { + "heimdalljs-logger": "0.1.9", + "quick-temp": "0.1.8", + "rsvp": "4.8.2", + "semver": "5.5.0", + "silent-error": "1.1.0" + }, + "dependencies": { + "rsvp": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.2.tgz", + "integrity": "sha512-8CU1Wjxvzt6bt8zln+hCjyieneU9s0LRW+lPRsjyVCY8Vm1kTbK7btBIrCGg6yY9U4undLDm/b1hKEEi1tLypg==" + } + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "requires": { + "defaults": "1.0.3" + } + }, + "websocket-driver": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", + "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=", + "requires": { + "http-parser-js": "0.4.11", + "websocket-extensions": "0.1.3" + } + }, + "websocket-extensions": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", + "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==" + }, + "whatwg-fetch": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", + "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" + }, + "whet.extend": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz", + "integrity": "sha1-+HfVv2SMl+WqVC+twW1qJZucEaE=" + }, + "which": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", + "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", + "requires": { + "isexe": "2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" + }, + "wide-align": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz", + "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", + "requires": { + "string-width": "1.0.2" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "optional": true + }, + "winston": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.1.tgz", + "integrity": "sha512-k/+Dkzd39ZdyJHYkuaYmf4ff+7j+sCIy73UCOWHYA67/WXU+FF/Y6PF28j+Vy7qNRPHWO+dR+/+zkoQWPimPqg==", + "requires": { + "async": "1.0.0", + "colors": "1.0.3", + "cycle": "1.0.3", + "eyes": "0.1.8", + "isstream": "0.1.2", + "stack-trace": "0.0.10" + }, + "dependencies": { + "async": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.0.0.tgz", + "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=" + } + } + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, + "workerpool": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-2.3.0.tgz", + "integrity": "sha512-JP5DpviEV84zDmz13QnD4FfRjZBjnTOYY2O4pGgxtlqLh47WOzQFHm8o17TE5OSfcDoKC6vHSrN4yPju93DW0Q==", + "requires": { + "object-assign": "4.1.1" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "requires": { + "string-width": "1.0.2", + "strip-ansi": "3.0.1" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "wrench": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/wrench/-/wrench-1.3.9.tgz", + "integrity": "sha1-bxPsNRRTF+spLKX2UxORskQRFBE=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "0.5.1" + } + }, + "write-file-atomic": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.3.0.tgz", + "integrity": "sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA==", + "requires": { + "graceful-fs": "4.1.11", + "imurmurhash": "0.1.4", + "signal-exit": "3.0.2" + } + }, + "ws": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.1.tgz", + "integrity": "sha1-CC3bbGQehdS7RR8D1S8G6r2x8Bg=", + "requires": { + "options": "0.0.6", + "ultron": "1.0.2" + } + }, + "wtf-8": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz", + "integrity": "sha1-OS2LotDxw00e4tYw8V0O+2jhBIo=" + }, + "xdg-basedir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", + "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=" + }, + "xmldom": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", + "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" + }, + "xmlhttprequest-ssl": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz", + "integrity": "sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=" + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, + "yam": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/yam/-/yam-0.0.24.tgz", + "integrity": "sha512-llPF60oFLV8EQimNPR6+KorSaj59L32C4c1db4cr72GaWVWapnhTS2VZeK2K2xLyEOveWtRcNa+dLJBW7EfhYQ==", + "requires": { + "fs-extra": "4.0.3", + "lodash.merge": "4.6.1" + }, + "dependencies": { + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + } + } + }, + "yargs": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-6.6.0.tgz", + "integrity": "sha1-eC7CHvQDNF+DCoCMo9UTr1YGUgg=", + "requires": { + "camelcase": "3.0.0", + "cliui": "3.2.0", + "decamelize": "1.2.0", + "get-caller-file": "1.0.2", + "os-locale": "1.4.0", + "read-pkg-up": "1.0.1", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "1.0.2", + "which-module": "1.0.0", + "y18n": "3.2.1", + "yargs-parser": "4.2.1" + } + }, + "yargs-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-4.2.1.tgz", + "integrity": "sha1-KczqwNxPA8bIe0qfIX3RjJ90hxw=", + "requires": { + "camelcase": "3.0.0" + } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" + }, + "zen-observable": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.7.1.tgz", + "integrity": "sha512-OI6VMSe0yeqaouIXtedC+F55Sr6r9ppS7+wTbSexkYdHbdt4ctTuPNXP/rwm7GTVI63YBc+EBT0b0tl7YnJLRg==" + } + } +} diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 00000000..6fde00e5 --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,75 @@ +{ + "name": "accent-webapp", + "version": "0.0.1", + "description": "The new new Accent Web app", + "private": true, + "contributors": [ + "Charles Demers ", + "Simon Prévost ", + "Luc-Olivier Boulet " + ], + "directories": { + "doc": "doc", + "test": "tests" + }, + "scripts": { + "start": "ember server --port $WEBAPP_PORT", + "build": "ember build", + "build-production": "ember build --prod", + "test": "ember test", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "prettier": "prettier --single-quote --no-bracket-spacing --print-width 130 --write './app/**/*.{js,gql}'", + "prettier-check": "prettier --single-quote --list-different --no-bracket-spacing --print-width 130 './app/**/*.{js,gql}'" + }, + "repository": "https://github.com/mirego/", + "engines": { + "node": ">= 8.5.0" + }, + "dependencies": { + "apollo-boost": "0.1.3", + "babel-plugin-transform-object-rest-spread": "6.26.0", + "broccoli-asset-rev": "2.6.0", + "date-fns": "1.29.0", + "diff": "3.5.0", + "ember-browserify": "1.2.1", + "ember-checkbox-with-label": "1.1.0", + "ember-cli": "3.1.0-beta.1", + "ember-cli-autoprefixer": "0.8.1", + "ember-cli-babel": "6.12.0", + "ember-cli-content-security-policy": "1.0.0", + "ember-cli-dependency-checker": "2.1.0", + "ember-cli-flash": "1.6.3", + "ember-cli-graphql-file": "0.0.1", + "ember-cli-htmlbars": "2.0.3", + "ember-cli-htmlbars-inline-precompile": "1.0.2", + "ember-cli-inject-live-reload": "1.7.0", + "ember-cli-mocha": "0.15.0", + "ember-cli-node-assets": "0.1.6", + "ember-cli-sass": "7.1.7", + "ember-cli-shims": "1.2.0", + "ember-cli-uglify": "2.0.2", + "ember-component-css": "0.6.3", + "ember-fetch": "3.4.4", + "ember-i18n": "5.2.0", + "ember-inline-svg": "0.1.11", + "ember-load-initializers": "1.0.0", + "ember-power-select": "2.0.0-beta.3", + "ember-radio-button": "1.2.3", + "ember-resolver": "4.5.4", + "ember-sinon": "1.0.1", + "ember-source": "3.1.0-beta.5", + "ember-wormhole": "0.5.4", + "graphql": "0.13.2", + "loader.js": "4.6.0", + "raven-js": "3.23.3", + "simple-fmt": "0.1.0", + "spin.js": "2.3.2" + }, + "devDependencies": { + "eslint": "4.19.0", + "eslint-config-prettier": "2.9.0", + "eslint-plugin-ember": "5.1.0", + "prettier": "1.11.1" + } +} diff --git a/webapp/public/assets/activity.svg b/webapp/public/assets/activity.svg new file mode 100644 index 00000000..351c798e --- /dev/null +++ b/webapp/public/assets/activity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/add.svg b/webapp/public/assets/add.svg new file mode 100644 index 00000000..a772fc74 --- /dev/null +++ b/webapp/public/assets/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/badge.svg b/webapp/public/assets/badge.svg new file mode 100644 index 00000000..6020ec03 --- /dev/null +++ b/webapp/public/assets/badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/bot.svg b/webapp/public/assets/bot.svg new file mode 100644 index 00000000..0dddf26d --- /dev/null +++ b/webapp/public/assets/bot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/bubble.svg b/webapp/public/assets/bubble.svg new file mode 100644 index 00000000..f1e58cfe --- /dev/null +++ b/webapp/public/assets/bubble.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/burger.svg b/webapp/public/assets/burger.svg new file mode 100644 index 00000000..082dfa20 --- /dev/null +++ b/webapp/public/assets/burger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/check.svg b/webapp/public/assets/check.svg new file mode 100644 index 00000000..c819bc3d --- /dev/null +++ b/webapp/public/assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/chevron-left.svg b/webapp/public/assets/chevron-left.svg new file mode 100644 index 00000000..a8b3fe34 --- /dev/null +++ b/webapp/public/assets/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/chevron-right.svg b/webapp/public/assets/chevron-right.svg new file mode 100644 index 00000000..45bd951b --- /dev/null +++ b/webapp/public/assets/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/chevron-top.svg b/webapp/public/assets/chevron-top.svg new file mode 100644 index 00000000..cb752c70 --- /dev/null +++ b/webapp/public/assets/chevron-top.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/empty.svg b/webapp/public/assets/empty.svg new file mode 100644 index 00000000..3cbaada7 --- /dev/null +++ b/webapp/public/assets/empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/export.svg b/webapp/public/assets/export.svg new file mode 100644 index 00000000..7b153405 --- /dev/null +++ b/webapp/public/assets/export.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/eye.svg b/webapp/public/assets/eye.svg new file mode 100644 index 00000000..a8d905b4 --- /dev/null +++ b/webapp/public/assets/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/favicon.png b/webapp/public/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..e08efa5b6d68f934c6d5b0e5775b95c3dece472e GIT binary patch literal 3671 zcmX|EbzBor*Pb*(9U~nfFhW8Y13_tFBZh#)kPwwI#+Wn%M@UKxX(`2_bV^D{NXWpT zO9dnq5F{i71cYzA-}`(2xc8oW@43%&&iUOqH^$UN{~VAH2mk=ip$w4bwAS&ourSeT z&-pYo0C0w%fVTFrHa1eldbr7996cOxa(-@}w0QtPUCYlCgLT3A2s_~L1a}SSMspif znBb@ZwNf&cH}*u}oCyX2-Z+Z@6Err!1*_r+)zSp2`>E0Z+;Bb^VLvxlccQAF2J}C; zsx7Qk3)X#>3z$Dk?B}1(@so8Yik?FJKL!#<#Cj7veFz@z!l#%R z2M=E#4JZ_LN&@>Y3(Z{E|Lmo8{^v2yo#v)D%^yC)vK3mGA_xeCDbZYCM;N7xP=>20 zE6Xa#!2tltWZ&fG8}!=j(OP-){A`s5m}IE6GgmxEt;Hjxcm%uX{{1J4Ad$rHa!>~X z=J~sK%*ssF)t^m{m>IN9Wx%doIvsjHD8>=wr#27vCTIG4*ZaeyeRa%Q;0%q7*MzuxUWcJJ@3j+=F6DCM148QVCt_OUmf2e$xE^kQt#30lh50s4ly@(Y zFgunz4wghA?ImT(y<2trtae--f1Rnl);6UX>jn~YYh0>4Ilq`}Gh}=Z$BTSGL$WM) zJCD!okVBR}UBzkOakF}($F#l4>S$ag@R$y}v}li@J)$6UwsD3HY|Q z>@BzOI;E!U8c7|#c6}E*J{_G8kM!yMlU!?>|9!WcmvAI)Olj<`M@|j&W`*^=UD}&G zoFZ;)9IvaiVLN_&wKHLpKUWM`l)Qa02~uTj_F#iPfWF=&+iaXK6zksH>5+e8TTPt^ zi>Yd){B1YP23phEiIps0oVCbi`npoDrYcq^b8dvifv6%AYRLlT;kO?KtumE{@ij~h z*@yO3QH|K2_Yia``_d8YudxfUI-rC{3Wd$XI_t zHZCXpt6e{T*iu+od@VoYpLV8(sp>w-M^UF7OJ@eoG+e;kWWEo6_pqWtq|w;sSzyTmM<#+#b~zGL#FZ^4N91;c0hl7O#d5kz}zJEwc|_qf&W0yY{@ktj5c zv1U>k;@d9WFy)1I$}>NON@IZo9qU~j5}?SEl(B5fTL|S0qF~jdeK7s3xqWosf}KoR zM{yu|%Wm_^EO|O>I}^`-^{Dv8?OYD(qPG*>bFhCA!#wK5on{3;fgXHH6&pqhYfP^9 z3ftnYQXd|?2Gve|u_?t058o8`bQ9Ci-8R0{h>vi68h}^}(V=>;~+(8x|x)7EIiw`%_w=};4lAs?;{O~MK z1y^$5b|C-95f>Ct;zp7Jwb7E5clIJDE@>n8!^*rvJj6K{34SF&Mv%}{SsoGE}CZhYO}9tH`qxEB%A^>cjhoZDDO7vT3UXn1Fd zw5&Ghlw@UQ21GLj%im41?Eae@KOJ?;_TGsBLp_KgkS3!4BeFN&2Tj2 zC;2{;ZKBtHrO@nNsgTy}cHCggW{}=Nej+ZV?6@(CQ#6%xeqm2(4tVm1l8i3`Y(Tg(S(#>p~iV zZ8^=y_M>Z*Z&U4FdsZCk!|_8z4@TPYVkEQlT=6s6HT;Kb@c0+k`lv9zg-2#vT0ii^ z+EYo7g}uK7e_yp}?L?|_kU(&k(jsOUY0b_T2XrVs)X#8%(f4HyPyRW7cIe1lwlxQ{ z>~_PRJ5=`>b1;X$MPi;qc4LaiNiBEV&9>zqHW|DY-W#&^@IKu$+5B(5K7N|&0fe_a zx@dcMs-o-ypy=d_&il54KH(%?fFdG;?3bqcF1aA+Gyqt-eK6hT;zBuF)=crKr(CVnibYl8jOi}aAKgTMu(vm^;T|dR8 zxp>MV30HgbR&&zw9m`Gvf?vK&H=ewp8f^Fn{^Vn-vaBuJ)F0;M@gfCkowekfAb2E6 zcI+s2K(aMrOMhM1#XV*P+k!_1=sI0AX1j&7QSbasxmKtdXR{20_Azjh!aTAz{n z!tmpO$KtT%4K$nT=oq4Bac%Z$3!i-@x7qVS_9IOp+2;*qhU*26H!+_Nq-*44(jC;k zC}-IGDl_yiA3{oGlGlKGH)U+b+i-$LTI zj!_wW>BPy`=$HIi8uBJd#}d9G?z>0cpDx;IZ>3fdZ$qI{vXPLzZ`7T!FAgXZU~|Z? z^hAiWMqwue?SjNqx3347hZ~KY|xMBNAe79`p0q!hBcSQ1}@4kYe4n8sJCdhMtVQ(|h z@6Ur0Gm>`Guk(Yiwfr(CpHQfAf$mVend>y27~aWrcNSP9gIKa=wr#q$MQWH{zhVpK ze(BpmocHpL9$*cyN15b0_bGt+Za<3do;p!!uTE?o97$u6@jPp`X(K(S z^X-S!a_X@+V@gl2V&hd>AfN5c@OaxI4z)-llY^zvf8A%_Se8T{m#ltPbNTfs>W!4j zvtHYKx(7RU<4$=_hJSrN3Eefe4Ngj$tjxD+%0G!BHLjgM89%lWeZ4@tBb(~iUjFtV zFsh`MS3)FacgG&>LTIT@ zKSs>RY9{&m!@IQVuvu4z@@0v{l~_b=>#>OD0LwEX@R`?uv9-mek5{UD+(?(hsx2l; zlf?|o^`r|xmy2-Q$|gbMM!VtTTopEa3gK`?i%JS=rMAT9i=c3bKaN@%i!cq^6$;bH zr`xwJg|xkVF#z%!YBA?LhgK5jc5luD_h(_hhO+AAP$B1N?{LlQ6UfY7zTRzCG<=bg z$eW+Zpr?2$I9_!qb*IBR({;Bn@{5uc?j4wkYCI+eYqdV$p#?$=mD~3KP@G)= zL|NZua})qKA(JWrywx~ZO+gW%FDjI z$QCa`CEYGm7R+V5!|W$5W2nXWt-L{&{x^LkAW^@Gqn7Wi3hGn9tpU0QK3cXlO|Kjg zFmj0v5mKj|vCZ7RP3|)4XI=s4AtVHnvYDg$L8h(N;#EAv#3WapeQQGg1@_KgO*NSV zP$YQCk{Uc3L=SbWWZE^l;`E8dh?r-&ScI0EblBPJ13ipl&t6|7#@soFFZc0y~9t!V84MBnt+KV8)kAv{W_s3TBomH_2r7b4)RMi2O!O f(5f)(dHM;xxmTfwsb_4>>3 \ No newline at end of file diff --git a/webapp/public/assets/fullscreen-minimize.svg b/webapp/public/assets/fullscreen-minimize.svg new file mode 100644 index 00000000..3e42c06e --- /dev/null +++ b/webapp/public/assets/fullscreen-minimize.svg @@ -0,0 +1 @@ +fullscreen diff --git a/webapp/public/assets/fullscreen.svg b/webapp/public/assets/fullscreen.svg new file mode 100644 index 00000000..c77db3ae --- /dev/null +++ b/webapp/public/assets/fullscreen.svg @@ -0,0 +1 @@ +iconmonstr-resize-5 diff --git a/webapp/public/assets/gear.svg b/webapp/public/assets/gear.svg new file mode 100644 index 00000000..575d0eb3 --- /dev/null +++ b/webapp/public/assets/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/google-logo.png b/webapp/public/assets/google-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..64471f28936604b9d2fc476ba027d58f093a460f GIT binary patch literal 3560 zcmcInUMFi<)G$M=~HEBUE-G~m5j?q0qQNqz7APoW=I6y)= zN3)4;bbk5$5x?j2InO!Io9E4Wb56XGp*AA}9|Hvi1*5JG1bV}m{{tP>&CZXptGpp< zSGA{V6clyH+ZXmUH}_wDP;GUJ>Jk2(8;vkBFx510^SS>2ne&Me;>quu-@6)5q{>ay zqYuv8Y1E_18c`RQSJ&~)J5pt48gEa}+h}$*W!opuA#bivS{ruMpV(#}U3dTWq6=|- zaki(Y@C$A-u!WOHTOO5vkg6~yKsArfPp?KPRVv|U*nX>4KXN=mx*W>!>G=hI^&kg1 zMV!ArIw+fOd{y=9qKom8NRX(O+xK$XIwJV@C7e%B{t5HBA~xssZl8_YA+Q-ayhC4 zF0(>S)|sY}6*H?6M^V=&>xS{iKDBDUIvMtzBvcb7o}%@engz2O#3$Q0Ps+KKKSU1f zZXc!#kV3>rQNq4GO9u@RFIsh{Zj1r?Lk&$R)_bi;Pj2x}hy<%@oxb*LEBS@2NL{Lq z&valKLXp;Y`{s)%C~nW`Lexxy=ZL6t3qD=0LA50y!%4pGcR9^rK}B-~64yo=!hwZ_ z9Xycx4U&<>o3V&O&7x3wm-EeP$;{iIt*53Izu}JyFSyc>0q_8?psXJ6GKIw*Lk6dY z+(Jpa(TmC-TYiGgqppRh<6zf9;Y4Da8c$Ft(Gx7*ytv0N;KM(>UdYY9E?H-Og z0?B`HDJigzIW8gc3B$1m8A!Nq`<7PdP&kr(5RB(8xA@YW*lN>cu~YKYlL4F1WA=M$LS^rdlSDgJfK_>==waPPw(vR)G_a>83DFx!27*DAQ3KBdlf zBvP4VICO-QxMegC;A5@t3(s_y`J4wu}eu5|i$e@jH7>zj&y z1b@f)5jo4U+AlEp!N<|sOs>oJEMYANg{%VAIq%jMIL(INx1V=$+f+stS(;s9Z1E91 zORa6sMu1r{@PFc+)rH*JM@MVL+@@qNd^`s>K(5T3a!>>hVU45i3;E2KLNLc~U5>?9 z-CYwZR#}c=jr9Ra7Kegyw0n$;(;DeHiZwzT$g~yJIN5U{9BOJPiX$*Uy5F296uqWX zNh?Rq9%#~kQtn+WA*5*@-MRF1gS^v8IvKDnOph}1UF8PPmRh9-f3+Rqd;kDIiJ1#C zTeI*e!QO6-dF#q zN$0B_%v=1W%Z8mR(!eFoteYBBF)w_#zao!JSQaDe+mEzgr9up*1MoZ#BXBJ1%x4So+#^MFdI%gO&o=%0~{v>Oqj>M0%Gr7Tu zyP`TqX-3Cmiiuts{#ZYJaF#hq2M%o)x!#d1ma30h zrBl?NLp+422q4(vV_DZbT!23Wjqv{O?j`F)o_+OAjTRl4-x61IfsMyelME$UY-aLl zh+5vjHh80TT5R!M`cg7tIJV8JiE5VmP@p#n2*{6k*saO}z@XQ45G`uJA}s;x`*vxI z>pCZKfK9==H1EJ-WUjW#MXzd-U=d41h*NeQgqJZDW>nS04v7B1YW?AR-)A^KrXWp} zPpSA4qhP~(t*emAd?PvL&1H^utF(YWq6Np8e$eNWMBS9jTQ&S6zlzQT8m)9l1ADeh zl#IChBWKvKk8971J;^e?=VF_x=&$*}zG`~wd8?yxm-OpQ=LQBS~ znDN)AY;aSn1QnFZ+iX2G7^xgl1wDxdGIMV*zO-j`g&mi_drV|z>k=a01J73XejNE) z!S`y2<9wmU)XP?PQ-kBaC{~yjUeZEyDnMzTgb=9DIeZ##&%IrxT(yIgXU8`v{P z2Skd7^Rpb-3QLY3PFc#C{xWecu;S4;_Pz|GnbnY&Ybq! zW!qgBQ89L}KD~6BXKC(+2Avz1;cAZ1VHbJGwZ%3setyQ!) zf=m-<_}Ky32xdUsHeolt%xtKh-yCl|vS52~XZmoH%AFTn(n|9`BVgre< z5P*cYt4UyVOc5Qc{mTsy6K%n`pTADb5OPEdoxV z<$pE;!M(Kte{;EWv2K8YmA+^yVZbW4p6dDCX5CINvr=0bfr7mLbu-L~MjbzpWAJL& zm5T4m{M8O$G2TO?9u6+FKq;g$%8@p-f3FqY*9^|%uFQbEDO4e3`c5ihNj;*IKt_u* zlcD!p1A_iDP)~T82b%uW_Tkn*)H9NHd4Ul_Isyky@(5Ck*f&>2+iLDe{D=-n!d1$s z^?0?ZqVuRrYMj`4SEPDzubDaeF9o3#dzsWjcUtS~mm`_KX4+h61(8h}etxjdh7eh< zc7}c-K71`Ho1P*HBf_#gD!v#>u0lhpI4*5daGvN!1P%s>*I(=7)QL;mf?kKH*`!t)eHFW7l4C(aFML zV3_Cf#Hlhwa&$N{p#A+g=2&cc-q&@T*xEnk{qon;lZAH$DMIA@@k~d(e(SkGufhLT zV&?r+!o=m=SMLYrsD;B&?y=$x6YgV>FSMV16>E8=GWtvSVg?iD!^Uwq1@Q}zT}iFR8pxZfy@i-eqO`@2(ENey># qxH6v5KB$0~f^`Dk-URT#o$C!cO2#!(kmXGypwQJcgjB0PkNh9X<32_J literal 0 HcmV?d00001 diff --git a/webapp/public/assets/home.svg b/webapp/public/assets/home.svg new file mode 100644 index 00000000..773846e3 --- /dev/null +++ b/webapp/public/assets/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/language.svg b/webapp/public/assets/language.svg new file mode 100644 index 00000000..b99046d3 --- /dev/null +++ b/webapp/public/assets/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/loading.svg b/webapp/public/assets/loading.svg new file mode 100644 index 00000000..6f18a808 --- /dev/null +++ b/webapp/public/assets/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/lock--locked.svg b/webapp/public/assets/lock--locked.svg new file mode 100644 index 00000000..8ac1fcd5 --- /dev/null +++ b/webapp/public/assets/lock--locked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/lock--unlocked.svg b/webapp/public/assets/lock--unlocked.svg new file mode 100644 index 00000000..d001fd1b --- /dev/null +++ b/webapp/public/assets/lock--unlocked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/logo-bw.svg b/webapp/public/assets/logo-bw.svg new file mode 100644 index 00000000..b0bd2e5d --- /dev/null +++ b/webapp/public/assets/logo-bw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/logo.svg b/webapp/public/assets/logo.svg new file mode 100644 index 00000000..c28f6dbc --- /dev/null +++ b/webapp/public/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/merge.svg b/webapp/public/assets/merge.svg new file mode 100644 index 00000000..64c233c7 --- /dev/null +++ b/webapp/public/assets/merge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/pencil.svg b/webapp/public/assets/pencil.svg new file mode 100644 index 00000000..eb2f305f --- /dev/null +++ b/webapp/public/assets/pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/reload.svg b/webapp/public/assets/reload.svg new file mode 100644 index 00000000..6c134d74 --- /dev/null +++ b/webapp/public/assets/reload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/revert.svg b/webapp/public/assets/revert.svg new file mode 100644 index 00000000..70c9633e --- /dev/null +++ b/webapp/public/assets/revert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/search.svg b/webapp/public/assets/search.svg new file mode 100644 index 00000000..7b9af0db --- /dev/null +++ b/webapp/public/assets/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/services/slack.svg b/webapp/public/assets/services/slack.svg new file mode 100644 index 00000000..d274c6a5 --- /dev/null +++ b/webapp/public/assets/services/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/share.svg b/webapp/public/assets/share.svg new file mode 100644 index 00000000..741bbd7c --- /dev/null +++ b/webapp/public/assets/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/sync.svg b/webapp/public/assets/sync.svg new file mode 100644 index 00000000..920072b7 --- /dev/null +++ b/webapp/public/assets/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/tag.svg b/webapp/public/assets/tag.svg new file mode 100644 index 00000000..61dc5aca --- /dev/null +++ b/webapp/public/assets/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/thumbs-up.svg b/webapp/public/assets/thumbs-up.svg new file mode 100644 index 00000000..b5193599 --- /dev/null +++ b/webapp/public/assets/thumbs-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/users.svg b/webapp/public/assets/users.svg new file mode 100644 index 00000000..92ba8a71 --- /dev/null +++ b/webapp/public/assets/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/assets/x.svg b/webapp/public/assets/x.svg new file mode 100644 index 00000000..8e4ea3da --- /dev/null +++ b/webapp/public/assets/x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/public/crossdomain.xml b/webapp/public/crossdomain.xml new file mode 100644 index 00000000..0c16a7a0 --- /dev/null +++ b/webapp/public/crossdomain.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/webapp/public/favicon.png b/webapp/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..e08efa5b6d68f934c6d5b0e5775b95c3dece472e GIT binary patch literal 3671 zcmX|EbzBor*Pb*(9U~nfFhW8Y13_tFBZh#)kPwwI#+Wn%M@UKxX(`2_bV^D{NXWpT zO9dnq5F{i71cYzA-}`(2xc8oW@43%&&iUOqH^$UN{~VAH2mk=ip$w4bwAS&ourSeT z&-pYo0C0w%fVTFrHa1eldbr7996cOxa(-@}w0QtPUCYlCgLT3A2s_~L1a}SSMspif znBb@ZwNf&cH}*u}oCyX2-Z+Z@6Err!1*_r+)zSp2`>E0Z+;Bb^VLvxlccQAF2J}C; zsx7Qk3)X#>3z$Dk?B}1(@so8Yik?FJKL!#<#Cj7veFz@z!l#%R z2M=E#4JZ_LN&@>Y3(Z{E|Lmo8{^v2yo#v)D%^yC)vK3mGA_xeCDbZYCM;N7xP=>20 zE6Xa#!2tltWZ&fG8}!=j(OP-){A`s5m}IE6GgmxEt;Hjxcm%uX{{1J4Ad$rHa!>~X z=J~sK%*ssF)t^m{m>IN9Wx%doIvsjHD8>=wr#27vCTIG4*ZaeyeRa%Q;0%q7*MzuxUWcJJ@3j+=F6DCM148QVCt_OUmf2e$xE^kQt#30lh50s4ly@(Y zFgunz4wghA?ImT(y<2trtae--f1Rnl);6UX>jn~YYh0>4Ilq`}Gh}=Z$BTSGL$WM) zJCD!okVBR}UBzkOakF}($F#l4>S$ag@R$y}v}li@J)$6UwsD3HY|Q z>@BzOI;E!U8c7|#c6}E*J{_G8kM!yMlU!?>|9!WcmvAI)Olj<`M@|j&W`*^=UD}&G zoFZ;)9IvaiVLN_&wKHLpKUWM`l)Qa02~uTj_F#iPfWF=&+iaXK6zksH>5+e8TTPt^ zi>Yd){B1YP23phEiIps0oVCbi`npoDrYcq^b8dvifv6%AYRLlT;kO?KtumE{@ij~h z*@yO3QH|K2_Yia``_d8YudxfUI-rC{3Wd$XI_t zHZCXpt6e{T*iu+od@VoYpLV8(sp>w-M^UF7OJ@eoG+e;kWWEo6_pqWtq|w;sSzyTmM<#+#b~zGL#FZ^4N91;c0hl7O#d5kz}zJEwc|_qf&W0yY{@ktj5c zv1U>k;@d9WFy)1I$}>NON@IZo9qU~j5}?SEl(B5fTL|S0qF~jdeK7s3xqWosf}KoR zM{yu|%Wm_^EO|O>I}^`-^{Dv8?OYD(qPG*>bFhCA!#wK5on{3;fgXHH6&pqhYfP^9 z3ftnYQXd|?2Gve|u_?t058o8`bQ9Ci-8R0{h>vi68h}^}(V=>;~+(8x|x)7EIiw`%_w=};4lAs?;{O~MK z1y^$5b|C-95f>Ct;zp7Jwb7E5clIJDE@>n8!^*rvJj6K{34SF&Mv%}{SsoGE}CZhYO}9tH`qxEB%A^>cjhoZDDO7vT3UXn1Fd zw5&Ghlw@UQ21GLj%im41?Eae@KOJ?;_TGsBLp_KgkS3!4BeFN&2Tj2 zC;2{;ZKBtHrO@nNsgTy}cHCggW{}=Nej+ZV?6@(CQ#6%xeqm2(4tVm1l8i3`Y(Tg(S(#>p~iV zZ8^=y_M>Z*Z&U4FdsZCk!|_8z4@TPYVkEQlT=6s6HT;Kb@c0+k`lv9zg-2#vT0ii^ z+EYo7g}uK7e_yp}?L?|_kU(&k(jsOUY0b_T2XrVs)X#8%(f4HyPyRW7cIe1lwlxQ{ z>~_PRJ5=`>b1;X$MPi;qc4LaiNiBEV&9>zqHW|DY-W#&^@IKu$+5B(5K7N|&0fe_a zx@dcMs-o-ypy=d_&il54KH(%?fFdG;?3bqcF1aA+Gyqt-eK6hT;zBuF)=crKr(CVnibYl8jOi}aAKgTMu(vm^;T|dR8 zxp>MV30HgbR&&zw9m`Gvf?vK&H=ewp8f^Fn{^Vn-vaBuJ)F0;M@gfCkowekfAb2E6 zcI+s2K(aMrOMhM1#XV*P+k!_1=sI0AX1j&7QSbasxmKtdXR{20_Azjh!aTAz{n z!tmpO$KtT%4K$nT=oq4Bac%Z$3!i-@x7qVS_9IOp+2;*qhU*26H!+_Nq-*44(jC;k zC}-IGDl_yiA3{oGlGlKGH)U+b+i-$LTI zj!_wW>BPy`=$HIi8uBJd#}d9G?z>0cpDx;IZ>3fdZ$qI{vXPLzZ`7T!FAgXZU~|Z? z^hAiWMqwue?SjNqx3347hZ~KY|xMBNAe79`p0q!hBcSQ1}@4kYe4n8sJCdhMtVQ(|h z@6Ur0Gm>`Guk(Yiwfr(CpHQfAf$mVend>y27~aWrcNSP9gIKa=wr#q$MQWH{zhVpK ze(BpmocHpL9$*cyN15b0_bGt+Za<3do;p!!uTE?o97$u6@jPp`X(K(S z^X-S!a_X@+V@gl2V&hd>AfN5c@OaxI4z)-llY^zvf8A%_Se8T{m#ltPbNTfs>W!4j zvtHYKx(7RU<4$=_hJSrN3Eefe4Ngj$tjxD+%0G!BHLjgM89%lWeZ4@tBb(~iUjFtV zFsh`MS3)FacgG&>LTIT@ zKSs>RY9{&m!@IQVuvu4z@@0v{l~_b=>#>OD0LwEX@R`?uv9-mek5{UD+(?(hsx2l; zlf?|o^`r|xmy2-Q$|gbMM!VtTTopEa3gK`?i%JS=rMAT9i=c3bKaN@%i!cq^6$;bH zr`xwJg|xkVF#z%!YBA?LhgK5jc5luD_h(_hhO+AAP$B1N?{LlQ6UfY7zTRzCG<=bg z$eW+Zpr?2$I9_!qb*IBR({;Bn@{5uc?j4wkYCI+eYqdV$p#?$=mD~3KP@G)= zL|NZua})qKA(JWrywx~ZO+gW%FDjI z$QCa`CEYGm7R+V5!|W$5W2nXWt-L{&{x^LkAW^@Gqn7Wi3hGn9tpU0QK3cXlO|Kjg zFmj0v5mKj|vCZ7RP3|)4XI=s4AtVHnvYDg$L8h(N;#EAv#3WapeQQGg1@_KgO*NSV zP$YQCk{Uc3L=SbWWZE^l;`E8dh?r-&ScI0EblBPJ13ipl&t6|7#@soFFZc0y~9t!V84MBnt+KV8)kAv{W_s3TBomH_2r7b4)RMi2O!O f(5f)(dHM;xxmTfwsb_4>>3 { + let application; + + beforeEach(() => { + application = startApp(); + }); + + afterEach(() => { + run(application, 'destroy'); + }); + + describe('Not logged', () => { + it('should be redirected to the login page if they access /', () => { + visit('/'); + + andThen(() => { + expect(currentPath()).to.equal('login'); + }); + }); + + it('should be able to access the login page', () => { + visit('login'); + + andThen(() => { + expect(currentPath()).to.equal('login'); + }); + }); + + it('should not be able to access the logged-in section', () => { + visit('app'); + + andThen(() => { + expect(currentPath()).to.equal('login'); + }); + }); + }); + + describe('Logged in', () => { + beforeEach(() => { + const credentials = { + user: {email: 'test@mirego.com'}, + token: 'abc123' + }; + + localStorage.setItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE, JSON.stringify(credentials)); + }); + + afterEach(() => { + localStorage.removeItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE); + }); + + it('should be redirected to the projects page if they access /', () => { + visit('/'); + + andThen(() => { + expect(currentPath()).to.equal('logged-in.projects'); + }); + }); + + it('should be redirected to the projects page if they access the login page', () => { + visit('login'); + + andThen(() => { + expect(currentPath()).to.equal('logged-in.projects'); + }); + }); + + it('should be able to access the projects page', () => { + visit('app/projects'); + + andThen(() => { + expect(currentPath()).to.equal('logged-in.projects'); + }); + }); + }); +}); diff --git a/webapp/tests/helpers/destroy-app.js b/webapp/tests/helpers/destroy-app.js new file mode 100644 index 00000000..38b2459b --- /dev/null +++ b/webapp/tests/helpers/destroy-app.js @@ -0,0 +1,7 @@ +/* eslint func-style:0 */ + +import { run } from '@ember/runloop'; + +export default function(application) { + run(application, 'destroy'); +} diff --git a/webapp/tests/helpers/flash-message.js b/webapp/tests/helpers/flash-message.js new file mode 100644 index 00000000..bfa28345 --- /dev/null +++ b/webapp/tests/helpers/flash-message.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; +import FlashObject from 'ember-cli-flash/flash/object'; + +const {K} = Ember; + +FlashObject.reopen({init: K}); diff --git a/webapp/tests/helpers/module-for-acceptance.js b/webapp/tests/helpers/module-for-acceptance.js new file mode 100644 index 00000000..e47c3079 --- /dev/null +++ b/webapp/tests/helpers/module-for-acceptance.js @@ -0,0 +1,25 @@ +/* eslint func-style:0 */ + +import { module } from 'qunit'; +import startApp from '../helpers/start-app'; +import destroyApp from '../helpers/destroy-app'; + +export default function(name, options = {}) { + module(name, { + beforeEach() { + this.application = startApp(); + + if (options.beforeEach) { + options.beforeEach.apply(this, arguments); + } + }, + + afterEach() { + if (options.afterEach) { + options.afterEach.apply(this, arguments); + } + + destroyApp(this.application); + } + }); +} diff --git a/webapp/tests/helpers/resolver.js b/webapp/tests/helpers/resolver.js new file mode 100644 index 00000000..b208d38d --- /dev/null +++ b/webapp/tests/helpers/resolver.js @@ -0,0 +1,11 @@ +import Resolver from '../../resolver'; +import config from '../../config/environment'; + +const resolver = Resolver.create(); + +resolver.namespace = { + modulePrefix: config.modulePrefix, + podModulePrefix: config.podModulePrefix +}; + +export default resolver; diff --git a/webapp/tests/helpers/start-app.js b/webapp/tests/helpers/start-app.js new file mode 100644 index 00000000..a4a8aa67 --- /dev/null +++ b/webapp/tests/helpers/start-app.js @@ -0,0 +1,22 @@ +/* eslint func-style:0 */ + +import { run } from '@ember/runloop'; + +import { merge } from '@ember/polyfills'; +import Application from '../../app'; +import config from '../../config/environment'; + +export default function startApp(attrs) { + let application; + + let attributes = merge({}, config.APP); + attributes = merge(attributes, attrs); // use defaults, but you can override; + + run(() => { + application = Application.create(attributes); + application.setupForTesting(); + application.injectTestHelpers(); + }); + + return application; +} diff --git a/webapp/tests/index.html b/webapp/tests/index.html new file mode 100644 index 00000000..658e6941 --- /dev/null +++ b/webapp/tests/index.html @@ -0,0 +1,34 @@ + + + + + + AccentWebapp Tests + + + + {{content-for "head"}} + {{content-for "test-head"}} + + + + + + {{content-for "head-footer"}} + {{content-for "test-head-footer"}} + + + {{content-for "body"}} + {{content-for "test-body"}} + + + + + + + + + {{content-for "body-footer"}} + {{content-for "test-body-footer"}} + + diff --git a/webapp/tests/test-helper.js b/webapp/tests/test-helper.js new file mode 100644 index 00000000..eb82b61f --- /dev/null +++ b/webapp/tests/test-helper.js @@ -0,0 +1,8 @@ +import resolver from './helpers/resolver'; +import './helpers/flash-message'; + +import registerSelectHelper from './helpers/register-select-helper'; +registerSelectHelper(); +import { setResolver } from 'ember-mocha'; + +setResolver(resolver); diff --git a/webapp/tests/unit/services/session/creator-test.js b/webapp/tests/unit/services/session/creator-test.js new file mode 100644 index 00000000..07517e1f --- /dev/null +++ b/webapp/tests/unit/services/session/creator-test.js @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { + describeModule, + it, + beforeEach, + afterEach +} from 'ember-mocha'; +import sinon from 'sinon'; + +const HTTP_STATUS = { + 'ok': 200 +}; + +describeModule( + 'service:session/creator', + 'service:session/creator', + { + needs: [] + }, + () => { + let xhr; + let requests; + let service; + + beforeEach(() => { + xhr = sinon.useFakeXMLHttpRequest(); + requests = []; + xhr.onCreate = (request) => requests.push(request); + + service = this.subject(); + }); + + afterEach(() => { + xhr.restore(); + }); + + it('should return a promise', () => { + expect(service.createSession({username: 'test@mirego.com'})).to.respondTo('then'); + }); + + it('should resolve with the credentials returned from the API', (done) => { + const credentialsJSON = '{"user":{"email":"test@mirego.com"},"token":"abc123"}'; + + service.createSession({username: 'test@mirego.com'}).then((credentials) => { + expect(credentials).to.deep.equal(JSON.parse(credentialsJSON)); + done(); + }); + + requests[0].respond(HTTP_STATUS.ok, {'Content-Type': 'application/json'}, credentialsJSON); + }); + } +); diff --git a/webapp/tests/unit/services/session/destroyer-test.js b/webapp/tests/unit/services/session/destroyer-test.js new file mode 100644 index 00000000..720fd144 --- /dev/null +++ b/webapp/tests/unit/services/session/destroyer-test.js @@ -0,0 +1,57 @@ +import EmberObject from '@ember/object'; +import { expect } from 'chai'; +import { + describeModule, + it, + beforeEach, + afterEach +} from 'ember-mocha'; +import config from 'accent-webapp/config/environment'; + +describeModule( + 'service:session/destroyer', + 'service:session/destroyer', + { + needs: [] + }, + () => { + let service; + + const credentials = { + user: {email: 'test@mirego.com'}, + token: 'abc123' + }; + + const sessionStub = EmberObject.extend({ + credentials: { + user: {email: 'test@mirego.com'}, + token: 'abc123' + } + }); + + beforeEach(() => { + service = this.subject({session: sessionStub.create()}); + + // Fake a previous login + localStorage.setItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE, service.get('session.credentials')); + + expect(service.get('session.credentials')).to.deep.equal(credentials); + }); + + afterEach(() => { + localStorage.removeItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE); + }); + + it('should nullify the session’s credentials', () => { + service.destroySession(); + + expect(service.get('session.credentials')).to.be.null; + }); + + it('should remove the credentials from localStorage', () => { + service.destroySession(); + + expect(localStorage.getItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE)).to.be.null; + }); + } +); diff --git a/webapp/tests/unit/services/session/fetcher-test.js b/webapp/tests/unit/services/session/fetcher-test.js new file mode 100644 index 00000000..02f6c836 --- /dev/null +++ b/webapp/tests/unit/services/session/fetcher-test.js @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { + describeModule, + it, + beforeEach, + afterEach +} from 'ember-mocha'; +import config from 'accent-webapp/config/environment'; + +describeModule( + 'service:session/fetcher', + 'service:session/fetcher', + { + needs: [] + }, + () => { + let service; + const credentials = { + user: {email: 'test@mirego.com'}, + token: 'abc123' + }; + + beforeEach(() => { + service = this.subject(); + + // Fake a previous login + localStorage.setItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE, JSON.stringify(credentials)); + }); + + afterEach(() => { + localStorage.removeItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE); + }); + + it('should read the session credentials from localStorage', () => { + expect(service.fetch()).to.deep.equal(credentials); + }); + } +); diff --git a/webapp/tests/unit/services/session/persister-test.js b/webapp/tests/unit/services/session/persister-test.js new file mode 100644 index 00000000..5a51cf52 --- /dev/null +++ b/webapp/tests/unit/services/session/persister-test.js @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { + describeModule, + it, + beforeEach, + afterEach +} from 'ember-mocha'; +import config from 'accent-webapp/config/environment'; + +describeModule( + 'service:session/persister', + 'service:session/persister', + { + needs: [] + }, + () => { + let service; + const credentials = { + user: {email: 'test@mirego.com'}, + token: 'abc123' + }; + + beforeEach(() => { + service = this.subject(); + }); + + afterEach(() => { + localStorage.removeItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE); + }); + + it('should persist the session credentials to localStorage', () => { + service.persist(credentials); + + expect(localStorage.getItem(config.APP.LOCAL_STORAGE.SESSION_NAMESPACE)).to.equal(JSON.stringify(credentials)); + }); + } +);