diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 300104bd18..8a21472ae2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -59,6 +59,9 @@ public function boot() // Define passport scopes $this->defineScopes(); + // need to use a string here or laravel goes wild and doesn't authorize anything + Gate::policy('v4\Models\Survey', 'v4\Policies\SurveyPolicy'); + Gate::policy('v4\Models\Category', 'v4\Policies\CategoryPolicy'); } protected function defineScopes() diff --git a/bootstrap/lumen.php b/bootstrap/lumen.php index fd78586f22..6d66e27024 100644 --- a/bootstrap/lumen.php +++ b/bootstrap/lumen.php @@ -94,6 +94,8 @@ $app->register(Ushahidi\App\Providers\PassportServiceProvider::class); $app->register(Barryvdh\Cors\ServiceProvider::class); $app->register(Sentry\SentryLaravel\SentryLumenServiceProvider::class); +$app->register(v4\Providers\MorphServiceProvider::class); + /* |-------------------------------------------------------------------------- @@ -111,5 +113,9 @@ ], function ($router) { require __DIR__.'/../routes/web.php'; }); - +$app->router->group([ + 'namespace' => 'v4\Http\Controllers', +], function ($router) { + require __DIR__.'/../v4/routes/web.php'; +}); return $app; diff --git a/composer.json b/composer.json index 6ff3cb3951..186fc0a096 100644 --- a/composer.json +++ b/composer.json @@ -81,7 +81,8 @@ "autoload": { "psr-4": { "Ushahidi\\": "src/", - "Ushahidi\\App\\": "app/" + "Ushahidi\\App\\": "app/", + "v4\\": "v4/" }, "files": [ "app/helpers.php" @@ -134,6 +135,11 @@ "phpspec run --no-code-generation", "behat --strict --profile ci" ], + "test-dev": [ + "phpunit --stop-on-failure", + "phpspec run", + "behat --strict" + ], "post-install-cmd": [ "\\SebastianFeldmann\\CaptainHook\\Composer\\Cmd::install", "php artisan passport:keys || php -r \"return 0;\"" diff --git a/migrations/20200506131856_add_entity_translations.php b/migrations/20200506131856_add_entity_translations.php new file mode 100644 index 0000000000..18f3b02e48 --- /dev/null +++ b/migrations/20200506131856_add_entity_translations.php @@ -0,0 +1,23 @@ +table('translations') + ->addColumn('translatable_type', 'string', ['null' => false]) //form, attribute,stage,category + ->addColumn('translatable_id', 'integer') + ->addColumn('translated_key', 'string', ['null' => false]) //name, title, keys + ->addColumn('translation', 'string', ['null' => false]) //name, title, keys + ->addColumn('language', 'string', ['null' => false]) //name, title, keys + ->addTimestamps() + ->create(); + } + public function down() + { + $this->dropTable('translations'); + } +} diff --git a/migrations/20200509204243_add_language_to_survey.php b/migrations/20200509204243_add_language_to_survey.php new file mode 100644 index 0000000000..bb6d344809 --- /dev/null +++ b/migrations/20200509204243_add_language_to_survey.php @@ -0,0 +1,24 @@ +fetchRow( + "SELECT config_value FROM config WHERE group_name='site' and config_key='language' " + ); + // get two letter lang code + $language = str_before(json_decode($result['config_value']), '-'); + $this->table('forms') + ->addColumn('base_language', 'string', ['null' => false, 'default' => $language]) //es/en + ->update(); + } + + public function down() + { + $this->table('forms')->removeColumn('base_language'); + } +} diff --git a/migrations/20200521014822_add_base_language_to_categories.php b/migrations/20200521014822_add_base_language_to_categories.php new file mode 100644 index 0000000000..93f4a20e2e --- /dev/null +++ b/migrations/20200521014822_add_base_language_to_categories.php @@ -0,0 +1,25 @@ +fetchRow( + "SELECT config_value FROM config WHERE group_name='site' and config_key='language' " + ); + // get two letter lang code + $language = str_before(json_decode($result['config_value']), '-'); + $this->table('tags') + ->addColumn('base_language', 'string', ['null' => false, 'default' => $language]) //es/en + ->update(); + } + + + public function down() + { + $this->table('tags')->removeColumn('base_language'); + } +} diff --git a/resources/lang/en/fields.php b/resources/lang/en/fields.php new file mode 100644 index 0000000000..f2d0fb9300 --- /dev/null +++ b/resources/lang/en/fields.php @@ -0,0 +1,25 @@ + 'Name', + 'disabled' => 'Disabled', + 'everyone_can_create' => 'Who can add to this survey?', + 'hide_author' => 'Hide author', + 'hide_location' => 'Hide location', + 'hide_time' => 'Hide time', + 'slug' => 'Slug', + 'tag' => 'Tag', + 'targeted_survey' => 'Targeted survey', + 'parent_id' => 'Parent category', + 'tasks.label' => 'Task label', + 'tasks.type' => 'Task type', + 'tasks.priority' => 'Priority', + 'tasks.icon' => 'Icon', + 'tasks.fields.label' => 'Field label', + 'tasks.fields.key' => 'Field key', + 'tasks.fields.input' => 'Field input type', + 'tasks.fields.type' => 'Field type', + 'tasks.fields.priority' => 'Field priority', + 'tasks.fields.cardinality' => 'Field cardinality', + 'tasks.fields.response_private' => 'Should responses be private?', +); diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index d3d8faeaaa..43bfec7712 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -26,4 +26,6 @@ 'regex' => ':field does not match the required format', 'url' => ':field must be a url', 'failedToValidate' => 'Failed to validate %s entity', + 'exists' => ':field must exist', + 'unique' => ':field must be unique', ); diff --git a/resources/lang/es/fields.php b/resources/lang/es/fields.php new file mode 100644 index 0000000000..68198a5397 --- /dev/null +++ b/resources/lang/es/fields.php @@ -0,0 +1,25 @@ + 'Nombre', + 'disabled' => 'Deshabilitado', + 'everyone_can_create' => 'Quién puede agregar a esta encuesta?', + 'hide_author' => 'Ocultar autor', + 'hide_location' => 'Ocultar ubicación', + 'hide_time' => 'Ocultar horario', + 'slug' => 'Slug', + 'tag' => 'Categoria', + 'targeted_survey' => 'Encuesta guiada', + 'parent_id' => 'Categoría padre', + 'tasks.label' => 'Etiqueta de tarea', + 'tasks.type' => 'Tipo de tarea', + 'tasks.priority' => 'Prioridad de tarea', + 'tasks.icon' => 'Icono de tarea', + 'tasks.fields.label' => 'Etiqueta de campo', + 'tasks.fields.key' => 'Clave de campo', + 'tasks.fields.input' => 'Tipo de campo de entrada', + 'tasks.fields.type' => 'Tipo de campo', + 'tasks.fields.priority' => 'Prioridad de campo', + 'tasks.fields.cardinality' => 'Cardinalidad de campo', + 'tasks.fields.response_private' => 'Deberian las respuestas ser privadas?', +); diff --git a/resources/lang/es/validation.php b/resources/lang/es/validation.php index c95520ee36..3f10d9069a 100644 --- a/resources/lang/es/validation.php +++ b/resources/lang/es/validation.php @@ -27,4 +27,6 @@ 'url' => ':field debe ser una URL', 'failedToValidate' => 'Falla al validar la entidad %s', 'failedToCreateContact' => 'No se logró crear el contacto. Resultado: %s', + 'exists' => ':field debe existir', + 'unique' => ':field debe ser un valor único', ); diff --git a/src/App/Acl.php b/src/App/Acl.php index c95319c528..3df8a6a056 100644 --- a/src/App/Acl.php +++ b/src/App/Acl.php @@ -52,6 +52,7 @@ public function hasPermission(User $user, $permission) } } + protected function customRoleHasPermission(User $user, $permission) { $role = $this->role_repo->getByName($user->role); diff --git a/src/App/DataSource/Twitter/Twitter.php b/src/App/DataSource/Twitter/Twitter.php index 44b48162b5..f9b8545483 100644 --- a/src/App/DataSource/Twitter/Twitter.php +++ b/src/App/DataSource/Twitter/Twitter.php @@ -189,21 +189,23 @@ public function fetch($limit = false) } $user_id = $user['id_str']; // @todo Check for similar messages in the database before saving - /*** + /*** * Twitter links note: (message field) - * Best compromise I could find was just make the proper urls with user_id rather than only + * Best compromise I could find was just make the + * proper urls with user_id rather than only * tweet id (for which there is an unofficial formula)... * since there doesn't seem to be a way to grab the URL from the * API itself in the v1.1 search endpoint. * Fun fact: if the user id is wrong, twitter - * still takes you to the correct Tweet... they just use the tweet id + * still takes you to the correct Tweet... + * they just use the tweet id **/ $messages[] = [ 'type' => MessageType::TWITTER, 'contact_type' => Contact::TWITTER, 'from' => $user_id, 'to' => null, - 'message' => "https://twitter.com/$user_id/status/$id", + 'message' => "https://twitter.com/$user_id/status/$id", 'title' => 'From twitter on ' . $date, 'datetime' => $date, 'data_source_message_id' => $id, diff --git a/src/Core/CoreConfig.php b/src/Core/CoreConfig.php index 190a468e43..c513ec6461 100644 --- a/src/Core/CoreConfig.php +++ b/src/Core/CoreConfig.php @@ -354,6 +354,9 @@ public function define(Container $di) $di->params['Ushahidi\Core\Tool\Authorizer\FormAuthorizer'] = [ 'form_repo' => $di->lazyGet('repository.form'), ]; + $di->params['v4\Policies\SurveyPolicy'] = [ + 'form_repo' => $di->lazyGet('repository.form'), + ]; $di->set('authorizer.form_attribute', $di->lazyNew('Ushahidi\Core\Tool\Authorizer\FormAttributeAuthorizer')); $di->params['Ushahidi\Core\Tool\Authorizer\FormAttributeAuthorizer'] = [ 'stage_repo' => $di->lazyGet('repository.form_stage'), diff --git a/src/Core/Tool/Permissions/AclTrait.php b/src/Core/Tool/Permissions/AclTrait.php index 61952e3f0a..709f02dc8f 100644 --- a/src/Core/Tool/Permissions/AclTrait.php +++ b/src/Core/Tool/Permissions/AclTrait.php @@ -15,7 +15,7 @@ trait AclTrait { - protected $acl; + public $acl; public function setAcl(Acl $acl) { diff --git a/src/Core/Traits/PrivateDeployment.php b/src/Core/Traits/PrivateDeployment.php index 51e93aa0c6..640f561dc3 100644 --- a/src/Core/Traits/PrivateDeployment.php +++ b/src/Core/Traits/PrivateDeployment.php @@ -39,7 +39,7 @@ public function isPrivate() * Check if user can access deployment * @return boolean */ - public function canAccessDeployment(User $user) + public function canAccessDeployment($user) { // Only logged in users have access if the deployment is private if ($this->isPrivate() and !$user->id) { diff --git a/src/Init.php b/src/Init.php index 69141a3b02..5907048a6e 100644 --- a/src/Init.php +++ b/src/Init.php @@ -38,6 +38,7 @@ function service($what = null) 'Ushahidi\App\Providers\LumenAuraConfig', 'Ushahidi\Console\ConsoleConfig', ]); + } if ($what) { return $di->get($what); diff --git a/tests/datasets/ushahidi/Base.yml b/tests/datasets/ushahidi/Base.yml index 21d3f62338..f51f328793 100644 --- a/tests/datasets/ushahidi/Base.yml +++ b/tests/datasets/ushahidi/Base.yml @@ -233,6 +233,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "post" + priority: 1 - id: 2 form_id: 1 @@ -240,6 +241,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "task" + priority: 2 - id: 3 form_id: 1 @@ -247,6 +249,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "task" + priority: 3 - id: 4 form_id: 2 @@ -254,6 +257,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "post" + priority: 1 - id: 5 form_id: 3 @@ -261,6 +265,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "post" + priority: 1 - id: 6 form_id: 4 @@ -268,6 +273,7 @@ form_stages: show_when_published: 0 task_is_internal_only: 0 type: "task" + priority: 1 - id: 7 form_id: 4 @@ -275,6 +281,7 @@ form_stages: show_when_published: 1 task_is_internal_only: 0 type: "post" + priority: 2 - id: 8 form_id: 5 @@ -282,6 +289,7 @@ form_stages: show_when_published: 0 task_is_internal_only: 0 type: "post" + priority: 1 - id: 9 form_id: 7 @@ -289,6 +297,7 @@ form_stages: show_when_published: 0 task_is_internal_only: 0 type: "post" + priority: 1 - id: 10 form_id: 6 @@ -296,6 +305,7 @@ form_stages: show_when_published: 0 task_is_internal_only: 0 type: "post" + priority: 1 - id: 11 form_id: 8 @@ -303,6 +313,31 @@ form_stages: show_when_published: 0 task_is_internal_only: 0 type: "post" + priority: 1 + - + id: 12 + form_id: 8 + label: "Post" + show_when_published: 0 + task_is_internal_only: 1 + type: "task" + priority: 2 + - + id: 13 + form_id: 8 + label: "Post" + show_when_published: 0 + task_is_internal_only: 0 + type: "task" + priority: 3 + - + id: 14 + form_id: 8 + label: "Public task" + show_when_published: 1 + task_is_internal_only: 0 + type: "post" + priority: 4 form_attributes: - id: 1 diff --git a/tests/integration/bootstrap/PHPUnitFixtureContext.php b/tests/integration/bootstrap/PHPUnitFixtureContext.php index e0d3eb5489..f4e0033403 100644 --- a/tests/integration/bootstrap/PHPUnitFixtureContext.php +++ b/tests/integration/bootstrap/PHPUnitFixtureContext.php @@ -1,4 +1,4 @@ - 'twitter', 'contact_type' => 'twitter', 'from' => '1112222225', // twitter user id - 'message' => 'https://twitter.com/1112222225/status/abc125', + 'message' => 'https://twitter.com/1112222225/status/abc125', 'to' => null, 'title' => 'From twitter on Thu Apr 06 15:24:15 +0000 2017', 'data_source_message_id' => 'abc125', diff --git a/v4/Http/Controllers/CategoryController.php b/v4/Http/Controllers/CategoryController.php new file mode 100644 index 0000000000..9c125469e3 --- /dev/null +++ b/v4/Http/Controllers/CategoryController.php @@ -0,0 +1,209 @@ +with('translations')->find($id); + if (!$category) { + return response()->json( + [ + 'errors' => [ + 'error' => 404, + 'message' => 'Not found', + ], + ], + 404 + ); + } + + return new CategoryResource($category); + }//end show() + + + /** + * Display the specified resource. + * + * @return CategoryCollection + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function index() + { + return new CategoryCollection(Category::allowed()->get()); + }//end index() + + + /** + * Display the specified resource. + * + * @TODO transactions =) + * @param Request $request + * @return SurveyResource + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function store(Request $request) + { + $authorizer = service('authorizer.form'); + // if there's no user the guards will kick them off already, but if there + // is one we need to check the authorizer to ensure we don't let + // users without admin perms create forms etc + // this is an unfortunate problem with using an old version of lumen + // that doesn't let me do guest user checks without adding more risk. + $user = $authorizer->getUser(); + if ($user) { + $this->authorize('store', Category::class); + } + $input = $request->input(); + $input['slug'] = Category::makeSlug($input['slug'] ?? $input['tag']); + $category = new Category(); + if (!$category->validate($input)) { + return response()->json($category->errors, 422); + } + + $category = Category::create( + array_merge( + $input, + [ + 'created' => time(), + ] + ) + ); + $this->saveTranslations($request->input('translations'), $category->id, 'category'); + return new CategoryResource($category); + }//end store() + + + /** + * @param $input + * @param $translatable_id + * @param $type + * @return boolean + */ + private function saveTranslations($input, int $translatable_id, string $type) + { + if (!is_array($input)) { + return true; + } + + foreach ($input as $language => $translations) { + foreach ($translations as $key => $translated) { + if (is_array($translated)) { + $translated = json_encode($translated); + } + + Translation::create( + [ + 'translatable_type' => $type, + 'translatable_id' => $translatable_id, + 'translated_key' => $key, + 'translation' => $translated, + 'language' => $language, + ] + ); + } + } + }//end saveTranslations() + + + /** + * Display the specified resource. + * + * @TODO transactions =) + * @param integer $id + * @param Request $request + * @return mixed + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function update(int $id, Request $request) + { + $category = Category::find($id); + + + if (!$category) { + return response()->json( + [ + 'errors' => [ + 'error' => 404, + 'message' => 'Not found', + ], + ], + 404 + ); + } + $this->authorize('update', $category); + + $input = $request->input(); + if (!$category->validate($input)) { + return response()->json($category->errors, 422); + } + + $category->update($request->input()); + $this->updateTranslations($request->input('translations'), $category->id, 'category'); + return new CategoryResource($category); + }//end update() + + + /** + * @param $input + * @param $translatable_id + * @param $type + * @return boolean + */ + private function updateTranslations($input, int $translatable_id, string $type) + { + if (!is_array($input)) { + return true; + } + + Translation::where('translatable_id', $translatable_id)->where('translatable_type', $type)->delete(); + foreach ($input as $language => $translations) { + foreach ($translations as $key => $translated) { + if (is_array($translated)) { + $translated = json_encode($translated); + } + + Translation::create( + [ + 'translatable_type' => $type, + 'translatable_id' => $translatable_id, + 'translated_key' => $key, + 'translation' => $translated, + 'language' => $language, + ] + ); + } + } + }//end updateTranslations() + + + /** + * @param integer $id + */ + public function delete(int $id, Request $request) + { + $category = Category::find($id); + $this->authorize('delete', $category); + $category->translations()->delete(); + $category->delete(); + return response()->json(['result' => ['deleted' => $id]]); + }//end delete() +}//end class diff --git a/v4/Http/Controllers/LanguagesController.php b/v4/Http/Controllers/LanguagesController.php new file mode 100644 index 0000000000..cfddfee82f --- /dev/null +++ b/v4/Http/Controllers/LanguagesController.php @@ -0,0 +1,1657 @@ + + * @package Ushahidi\Application + * @license https://www.gnu.org/licenses/agpl-3.0.html (AGPL3) + * @copyright 2020 Ushahidi + */ + +namespace v4\Http\Controllers; + +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Bus\DispatchesJobs; +use Illuminate\Foundation\Validation\ValidatesRequests; + +class LanguagesController extends V4Controller +{ + + + /** + * An silly endpoint that returns an array of languages + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $languages = [ + [ + 'code' => 'ach', + 'name' => 'Acoli', + ], + [ + 'code' => 'ady', + 'name' => 'Adyghe', + ], + [ + 'code' => 'af', + 'name' => 'Afrikaans', + ], + [ + 'code' => 'af-ZA', + 'name' => 'Afrikaans (South Africa)', + ], + [ + 'code' => 'ak', + 'name' => 'Akan', + ], + [ + 'code' => 'sq', + 'name' => 'Albanian', + ], + [ + 'code' => 'sq-AL', + 'name' => 'Albanian (Albania)', + ], + [ + 'code' => 'aln', + 'name' => 'Albanian Gheg', + ], + [ + 'code' => 'am', + 'name' => 'Amharic', + ], + [ + 'code' => 'am-ET', + 'name' => 'Amharic (Ethiopia)', + ], + [ + 'code' => 'ar', + 'name' => 'Arabic', + ], + [ + 'code' => 'ar-EG', + 'name' => 'Arabic (Egypt)', + ], + [ + 'code' => 'ar-SA', + 'name' => 'Arabic (Saudi Arabia)', + ], + [ + 'code' => 'ar-SD', + 'name' => 'Arabic (Sudan)', + ], + [ + 'code' => 'ar-SY', + 'name' => 'Arabic (Syria)', + ], + [ + 'code' => 'ar-AA', + 'name' => 'Arabic (Unitag)', + ], + [ + 'code' => 'an', + 'name' => 'Aragonese', + ], + [ + 'code' => 'hy', + 'name' => 'Armenian', + ], + [ + 'code' => 'hy-AM', + 'name' => 'Armenian (Armenia)', + ], + [ + 'code' => 'as', + 'name' => 'Assamese', + ], + [ + 'code' => 'as-IN', + 'name' => 'Assamese (India)', + ], + [ + 'code' => 'ast', + 'name' => 'Asturian', + ], + [ + 'code' => 'ast-ES', + 'name' => 'Asturian (Spain)', + ], + [ + 'code' => 'az', + 'name' => 'Azerbaijani', + ], + [ + 'code' => 'az@Arab', + 'name' => 'Azerbaijani (Arabic)', + ], + [ + 'code' => 'az-AZ', + 'name' => 'Azerbaijani (Azerbaijan)', + ], + [ + 'code' => 'az-IR', + 'name' => 'Azerbaijani (Iran)', + ], + [ + 'code' => 'az@latin', + 'name' => 'Azerbaijani (Latin)', + ], + [ + 'code' => 'bal', + 'name' => 'Balochi', + ], + [ + 'code' => 'ba', + 'name' => 'Bashkir', + ], + [ + 'code' => 'eu', + 'name' => 'Basque', + ], + [ + 'code' => 'eu-ES', + 'name' => 'Basque (Spain)', + ], + [ + 'code' => 'bar', + 'name' => 'Bavarian', + ], + [ + 'code' => 'be', + 'name' => 'Belarusian', + ], + [ + 'code' => 'be-BY', + 'name' => 'Belarusian (Belarus)', + ], + [ + 'code' => 'be@tarask', + 'name' => 'Belarusian (Tarask)', + ], + [ + 'code' => 'bn', + 'name' => 'Bengali', + ], + [ + 'code' => 'bn-BD', + 'name' => 'Bengali (Bangladesh)', + ], + [ + 'code' => 'bn-IN', + 'name' => 'Bengali (India)', + ], + [ + 'code' => 'brx', + 'name' => 'Bodo', + ], + [ + 'code' => 'bs', + 'name' => 'Bosnian', + ], + [ + 'code' => 'bs-BA', + 'name' => 'Bosnian (Bosnia and Herzegovina)', + ], + [ + 'code' => 'br', + 'name' => 'Breton', + ], + [ + 'code' => 'bg', + 'name' => 'Bulgarian', + ], + [ + 'code' => 'bg-BG', + 'name' => 'Bulgarian (Bulgaria)', + ], + [ + 'code' => 'my', + 'name' => 'Burmese', + ], + [ + 'code' => 'my-MM', + 'name' => 'Burmese (Myanmar)', + ], + [ + 'code' => 'ca', + 'name' => 'Catalan', + ], + [ + 'code' => 'ca-ES', + 'name' => 'Catalan (Spain)', + ], + [ + 'code' => 'ca@valencia', + 'name' => 'Catalan (Valencian)', + ], + [ + 'code' => 'ceb', + 'name' => 'Cebuano', + ], + [ + 'code' => 'tzm', + 'name' => 'Central Atlas Tamazight', + ], + [ + 'code' => 'hne', + 'name' => 'Chhattisgarhi', + ], + [ + 'code' => 'cgg', + 'name' => 'Chiga', + ], + [ + 'code' => 'zh', + 'name' => 'Chinese', + ], + [ + 'code' => 'zh-CN', + 'name' => 'Chinese (China)', + ], + [ + 'code' => 'zh-CN.GB2312', + 'name' => 'Chinese (China) (GB2312)', + ], + [ + 'code' => 'gan', + 'name' => 'Chinese (Gan)', + ], + [ + 'code' => 'hak', + 'name' => 'Chinese (Hakka)', + ], + [ + 'code' => 'zh-HK', + 'name' => 'Chinese (Hong Kong)', + ], + [ + 'code' => 'czh', + 'name' => 'Chinese (Huizhou)', + ], + [ + 'code' => 'cjy', + 'name' => 'Chinese (Jinyu)', + ], + [ + 'code' => 'lzh', + 'name' => 'Chinese (Literary)', + ], + [ + 'code' => 'cmn', + 'name' => 'Chinese (Mandarin)', + ], + [ + 'code' => 'mnp', + 'name' => 'Chinese (Min Bei)', + ], + [ + 'code' => 'cdo', + 'name' => 'Chinese (Min Dong)', + ], + [ + 'code' => 'nan', + 'name' => 'Chinese (Min Nan)', + ], + [ + 'code' => 'czo', + 'name' => 'Chinese (Min Zhong)', + ], + [ + 'code' => 'cpx', + 'name' => 'Chinese (Pu-Xian)', + ], + [ + 'code' => 'zh-Hans', + 'name' => 'Chinese Simplified', + ], + [ + 'code' => 'zh-TW', + 'name' => 'Chinese (Taiwan)', + ], + [ + 'code' => 'zh-TW.Big5', + 'name' => 'Chinese (Taiwan) (Big5) ', + ], + [ + 'code' => 'zh-Hant', + 'name' => 'Chinese Traditional', + ], + [ + 'code' => 'wuu', + 'name' => 'Chinese (Wu)', + ], + [ + 'code' => 'hsn', + 'name' => 'Chinese (Xiang)', + ], + [ + 'code' => 'yue', + 'name' => 'Chinese (Yue)', + ], + [ + 'code' => 'cv', + 'name' => 'Chuvash', + ], + [ + 'code' => 'ksh', + 'name' => 'Colognian', + ], + [ + 'code' => 'kw', + 'name' => 'Cornish', + ], + [ + 'code' => 'co', + 'name' => 'Corsican', + ], + [ + 'code' => 'crh', + 'name' => 'Crimean Turkish', + ], + [ + 'code' => 'hr', + 'name' => 'Croatian', + ], + [ + 'code' => 'hr-HR', + 'name' => 'Croatian (Croatia)', + ], + [ + 'code' => 'cs', + 'name' => 'Czech', + ], + [ + 'code' => 'cs-CZ', + 'name' => 'Czech (Czech Republic)', + ], + [ + 'code' => 'da', + 'name' => 'Danish', + ], + [ + 'code' => 'da-DK', + 'name' => 'Danish (Denmark)', + ], + [ + 'code' => 'dv', + 'name' => 'Divehi', + ], + [ + 'code' => 'doi', + 'name' => 'Dogri', + ], + [ + 'code' => 'nl', + 'name' => 'Dutch', + ], + [ + 'code' => 'nl-BE', + 'name' => 'Dutch (Belgium)', + ], + [ + 'code' => 'nl-NL', + 'name' => 'Dutch (Netherlands)', + ], + [ + 'code' => 'dz', + 'name' => 'Dzongkha', + ], + [ + 'code' => 'dz-BT', + 'name' => 'Dzongkha (Bhutan)', + ], + [ + 'code' => 'en', + 'name' => 'English', + ], + [ + 'code' => 'en-AU', + 'name' => 'English (Australia)', + ], + [ + 'code' => 'en-AT', + 'name' => 'English (Austria)', + ], + [ + 'code' => 'en-BD', + 'name' => 'English (Bangladesh)', + ], + [ + 'code' => 'en-BE', + 'name' => 'English (Belgium)', + ], + [ + 'code' => 'en-CA', + 'name' => 'English (Canada)', + ], + [ + 'code' => 'en-CL', + 'name' => 'English (Chile)', + ], + [ + 'code' => 'en-CZ', + 'name' => 'English (Czech Republic)', + ], + [ + 'code' => 'en-ee', + 'name' => 'English (Estonia)', + ], + [ + 'code' => 'en-FI', + 'name' => 'English (Finland)', + ], + [ + 'code' => 'en-DE', + 'name' => 'English (Germany)', + ], + [ + 'code' => 'en-GH', + 'name' => 'English (Ghana)', + ], + [ + 'code' => 'en-HK', + 'name' => 'English (Hong Kong)', + ], + [ + 'code' => 'en-HU', + 'name' => 'English (Hungary)', + ], + [ + 'code' => 'en-IN', + 'name' => 'English (India)', + ], + [ + 'code' => 'en-IE', + 'name' => 'English (Ireland)', + ], + [ + 'code' => 'en-lv', + 'name' => 'English (Latvia)', + ], + [ + 'code' => 'en-lt', + 'name' => 'English (Lithuania)', + ], + [ + 'code' => 'en-NL', + 'name' => 'English (Netherlands)', + ], + [ + 'code' => 'en-NZ', + 'name' => 'English (New Zealand)', + ], + [ + 'code' => 'en-NG', + 'name' => 'English (Nigeria)', + ], + [ + 'code' => 'en-PK', + 'name' => 'English (Pakistan)', + ], + [ + 'code' => 'en-PL', + 'name' => 'English (Poland)', + ], + [ + 'code' => 'en-RO', + 'name' => 'English (Romania)', + ], + [ + 'code' => 'en-SK', + 'name' => 'English (Slovakia)', + ], + [ + 'code' => 'en-ZA', + 'name' => 'English (South Africa)', + ], + [ + 'code' => 'en-LK', + 'name' => 'English (Sri Lanka)', + ], + [ + 'code' => 'en-SE', + 'name' => 'English (Sweden)', + ], + [ + 'code' => 'en-CH', + 'name' => 'English (Switzerland)', + ], + [ + 'code' => 'en-GB', + 'name' => 'English (United Kingdom)', + ], + [ + 'code' => 'en-US', + 'name' => 'English (United States)', + ], + [ + 'code' => 'en-EN', + 'name' => 'English', + ], + [ + 'code' => 'myv', + 'name' => 'Erzya', + ], + [ + 'code' => 'eo', + 'name' => 'Esperanto', + ], + [ + 'code' => 'et', + 'name' => 'Estonian', + ], + [ + 'code' => 'et-EE', + 'name' => 'Estonian (Estonia)', + ], + [ + 'code' => 'fo', + 'name' => 'Faroese', + ], + [ + 'code' => 'fo-FO', + 'name' => 'Faroese (Faroe Islands)', + ], + [ + 'code' => 'fil', + 'name' => 'Filipino', + ], + [ + 'code' => 'fi', + 'name' => 'Finnish', + ], + [ + 'code' => 'fi-FI', + 'name' => 'Finnish (Finland)', + ], + [ + 'code' => 'frp', + 'name' => 'Franco-Provençal (Arpitan)', + ], + [ + 'code' => 'fr', + 'name' => 'French', + ], + [ + 'code' => 'fr-BE', + 'name' => 'French (Belgium)', + ], + [ + 'code' => 'fr-CA', + 'name' => 'French (Canada)', + ], + [ + 'code' => 'fr-FR', + 'name' => 'French (France)', + ], + [ + 'code' => 'fr-CH', + 'name' => 'French (Switzerland)', + ], + [ + 'code' => 'fur', + 'name' => 'Friulian', + ], + [ + 'code' => 'ff', + 'name' => 'Fulah', + ], + [ + 'code' => 'ff-SN', + 'name' => 'Fulah (Senegal)', + ], + [ + 'code' => 'gd', + 'name' => 'Gaelic, Scottish', + ], + [ + 'code' => 'gl', + 'name' => 'Galician', + ], + [ + 'code' => 'gl-ES', + 'name' => 'Galician (Spain)', + ], + [ + 'code' => 'lg', + 'name' => 'Ganda', + ], + [ + 'code' => 'ka', + 'name' => 'Georgian', + ], + [ + 'code' => 'ka-GE', + 'name' => 'Georgian (Georgia)', + ], + [ + 'code' => 'de', + 'name' => 'German', + ], + [ + 'code' => 'de-AT', + 'name' => 'German (Austria)', + ], + [ + 'code' => 'de-DE', + 'name' => 'German (Germany)', + ], + [ + 'code' => 'de-CH', + 'name' => 'German (Switzerland)', + ], + [ + 'code' => 'el', + 'name' => 'Greek', + ], + [ + 'code' => 'el-GR', + 'name' => 'Greek (Greece)', + ], + [ + 'code' => 'kl', + 'name' => 'Greenlandic', + ], + [ + 'code' => 'gu', + 'name' => 'Gujarati', + ], + [ + 'code' => 'gu-IN', + 'name' => 'Gujarati (India)', + ], + [ + 'code' => 'gun', + 'name' => 'Gun', + ], + [ + 'code' => 'ht', + 'name' => 'Haitian (Haitian Creole)', + ], + [ + 'code' => 'ht-HT', + 'name' => 'Haitian (Haitian Creole) (Haiti)', + ], + [ + 'code' => 'ha', + 'name' => 'Hausa', + ], + [ + 'code' => 'haw', + 'name' => 'Hawaiian', + ], + [ + 'code' => 'he', + 'name' => 'Hebrew', + ], + [ + 'code' => 'he-IL', + 'name' => 'Hebrew (Israel)', + ], + [ + 'code' => 'hi', + 'name' => 'Hindi', + ], + [ + 'code' => 'hi-IN', + 'name' => 'Hindi (India)', + ], + [ + 'code' => 'hu', + 'name' => 'Hungarian', + ], + [ + 'code' => 'hu-HU', + 'name' => 'Hungarian (Hungary)', + ], + [ + 'code' => 'is', + 'name' => 'Icelandic', + ], + [ + 'code' => 'is-IS', + 'name' => 'Icelandic (Iceland)', + ], + [ + 'code' => 'io', + 'name' => 'Ido', + ], + [ + 'code' => 'ig', + 'name' => 'Igbo', + ], + [ + 'code' => 'ilo', + 'name' => 'Iloko', + ], + [ + 'code' => 'id', + 'name' => 'Indonesian', + ], + [ + 'code' => 'id-ID', + 'name' => 'Indonesian (Indonesia)', + ], + [ + 'code' => 'ia', + 'name' => 'Interlingua', + ], + [ + 'code' => 'iu', + 'name' => 'Inuktitut', + ], + [ + 'code' => 'ga', + 'name' => 'Irish', + ], + [ + 'code' => 'ga-IE', + 'name' => 'Irish (Ireland)', + ], + [ + 'code' => 'it', + 'name' => 'Italian', + ], + [ + 'code' => 'it-IT', + 'name' => 'Italian (Italy)', + ], + [ + 'code' => 'it-CH', + 'name' => 'Italian (Switzerland)', + ], + [ + 'code' => 'ja', + 'name' => 'Japanese', + ], + [ + 'code' => 'ja-JP', + 'name' => 'Japanese (Japan)', + ], + [ + 'code' => 'jv', + 'name' => 'Javanese', + ], + [ + 'code' => 'kab', + 'name' => 'Kabyle', + ], + [ + 'code' => 'kn', + 'name' => 'Kannada', + ], + [ + 'code' => 'kn-IN', + 'name' => 'Kannada (India)', + ], + [ + 'code' => 'pam', + 'name' => 'Kapampangan', + ], + [ + 'code' => 'ks', + 'name' => 'Kashmiri', + ], + [ + 'code' => 'ks-IN', + 'name' => 'Kashmiri (India)', + ], + [ + 'code' => 'csb', + 'name' => 'Kashubian', + ], + [ + 'code' => 'kk', + 'name' => 'Kazakh', + ], + [ + 'code' => 'kk@Arab', + 'name' => 'Kazakh (Arabic)', + ], + [ + 'code' => 'kk@Cyrl', + 'name' => 'Kazakh (Cyrillic)', + ], + [ + 'code' => 'kk-KZ', + 'name' => 'Kazakh (Kazakhstan)', + ], + [ + 'code' => 'kk@latin', + 'name' => 'Kazakh (Latin)', + ], + [ + 'code' => 'km', + 'name' => 'Khmer', + ], + [ + 'code' => 'km-KH', + 'name' => 'Khmer (Cambodia)', + ], + [ + 'code' => 'rw', + 'name' => 'Kinyarwanda', + ], + [ + 'code' => 'ky', + 'name' => 'Kirgyz', + ], + [ + 'code' => 'tlh', + 'name' => 'Klingon', + ], + [ + 'code' => 'kok', + 'name' => 'Konkani', + ], + [ + 'code' => 'ko', + 'name' => 'Korean', + ], + [ + 'code' => 'ko-KR', + 'name' => 'Korean (Korea)', + ], + [ + 'code' => 'ku', + 'name' => 'Kurdish', + ], + [ + 'code' => 'ku-IQ', + 'name' => 'Kurdish (Iraq)', + ], + [ + 'code' => 'lad', + 'name' => 'Ladino', + ], + [ + 'code' => 'lo', + 'name' => 'Lao', + ], + [ + 'code' => 'lo-LA', + 'name' => 'Lao (Laos)', + ], + [ + 'code' => 'ltg', + 'name' => 'Latgalian', + ], + [ + 'code' => 'la', + 'name' => 'Latin', + ], + [ + 'code' => 'lv', + 'name' => 'Latvian', + ], + [ + 'code' => 'lv-LV', + 'name' => 'Latvian (Latvia)', + ], + [ + 'code' => 'lez', + 'name' => 'Lezghian', + ], + [ + 'code' => 'lij', + 'name' => 'Ligurian', + ], + [ + 'code' => 'li', + 'name' => 'Limburgian', + ], + [ + 'code' => 'ln', + 'name' => 'Lingala', + ], + [ + 'code' => 'lt', + 'name' => 'Lithuanian', + ], + [ + 'code' => 'lt-LT', + 'name' => 'Lithuanian (Lithuania)', + ], + [ + 'code' => 'jbo', + 'name' => 'Lojban', + ], + [ + 'code' => 'en@lolcat', + 'name' => 'LOLCAT English', + ], + [ + 'code' => 'lmo', + 'name' => 'Lombard', + ], + [ + 'code' => 'dsb', + 'name' => 'Lower Sorbian', + ], + [ + 'code' => 'nds', + 'name' => 'Low German', + ], + [ + 'code' => 'lb', + 'name' => 'Luxembourgish', + ], + [ + 'code' => 'mk', + 'name' => 'Macedonian', + ], + [ + 'code' => 'mk-MK', + 'name' => 'Macedonian (Macedonia)', + ], + [ + 'code' => 'mai', + 'name' => 'Maithili', + ], + [ + 'code' => 'mg', + 'name' => 'Malagasy', + ], + [ + 'code' => 'ms', + 'name' => 'Malay', + ], + [ + 'code' => 'ml', + 'name' => 'Malayalam', + ], + [ + 'code' => 'ml-IN', + 'name' => 'Malayalam (India)', + ], + [ + 'code' => 'ms-MY', + 'name' => 'Malay (Malaysia)', + ], + [ + 'code' => 'mt', + 'name' => 'Maltese', + ], + [ + 'code' => 'mt-MT', + 'name' => 'Maltese (Malta)', + ], + [ + 'code' => 'mni', + 'name' => 'Manipuri', + ], + [ + 'code' => 'mi', + 'name' => 'Maori', + ], + [ + 'code' => 'arn', + 'name' => 'Mapudungun', + ], + [ + 'code' => 'mr', + 'name' => 'Marathi', + ], + [ + 'code' => 'mr-IN', + 'name' => 'Marathi (India)', + ], + [ + 'code' => 'mh', + 'name' => 'Marshallese', + ], + [ + 'code' => 'mw1', + 'name' => 'Mirandese', + ], + [ + 'code' => 'mn', + 'name' => 'Mongolian', + ], + [ + 'code' => 'mn-MN', + 'name' => 'Mongolian (Mongolia)', + ], + [ + 'code' => 'nah', + 'name' => 'Nahuatl', + ], + [ + 'code' => 'nv', + 'name' => 'Navajo', + ], + [ + 'code' => 'nr', + 'name' => 'Ndebele, South', + ], + [ + 'code' => 'nap', + 'name' => 'Neapolitan', + ], + [ + 'code' => 'ne', + 'name' => 'Nepali', + ], + [ + 'code' => 'ne-NP', + 'name' => 'Nepali (Nepal)', + ], + [ + 'code' => 'nia', + 'name' => 'Nias', + ], + [ + 'code' => 'nqo', + 'name' => "N'ko", + ], + [ + 'code' => 'se', + 'name' => 'Northern Sami', + ], + [ + 'code' => 'nso', + 'name' => 'Northern Sotho', + ], + [ + 'code' => 'no', + 'name' => 'Norwegian', + ], + [ + 'code' => 'nb', + 'name' => 'Norwegian Bokmål', + ], + [ + 'code' => 'nb-NO', + 'name' => 'Norwegian Bokmål (Norway)', + ], + [ + 'code' => 'no-NO', + 'name' => 'Norwegian (Norway)', + ], + [ + 'code' => 'nn', + 'name' => 'Norwegian Nynorsk', + ], + [ + 'code' => 'nn-NO', + 'name' => 'Norwegian Nynorsk (Norway)', + ], + [ + 'code' => 'ny', + 'name' => 'Nyanja', + ], + [ + 'code' => 'oc', + 'name' => 'Occitan (post 1500)', + ], + [ + 'code' => 'or', + 'name' => 'Oriya', + ], + [ + 'code' => 'or-IN', + 'name' => 'Oriya (India)', + ], + [ + 'code' => 'om', + 'name' => 'Oromo', + ], + [ + 'code' => 'os', + 'name' => 'Ossetic', + ], + [ + 'code' => 'pfl', + 'name' => 'Palatinate German', + ], + [ + 'code' => 'pa', + 'name' => 'Panjabi (Punjabi)', + ], + [ + 'code' => 'pa-IN', + 'name' => 'Panjabi (Punjabi) (India)', + ], + [ + 'code' => 'pap', + 'name' => 'Papiamento', + ], + [ + 'code' => 'fa', + 'name' => 'Persian', + ], + [ + 'code' => 'fa-AF', + 'name' => 'Persian (Afghanistan)', + ], + [ + 'code' => 'fa-IR', + 'name' => 'Persian (Iran)', + ], + [ + 'code' => 'pms', + 'name' => 'Piemontese', + ], + [ + 'code' => 'en@pirate', + 'name' => 'Pirate English', + ], + [ + 'code' => 'pl', + 'name' => 'Polish', + ], + [ + 'code' => 'pl-PL', + 'name' => 'Polish (Poland)', + ], + [ + 'code' => 'pt', + 'name' => 'Portuguese', + ], + [ + 'code' => 'pt-BR', + 'name' => 'Portuguese (Brazil)', + ], + [ + 'code' => 'pt-PT', + 'name' => 'Portuguese (Portugal)', + ], + [ + 'code' => 'ps', + 'name' => 'Pushto', + ], + [ + 'code' => 'ro', + 'name' => 'Romanian', + ], + [ + 'code' => 'ro-RO', + 'name' => 'Romanian (Romania)', + ], + [ + 'code' => 'rm', + 'name' => 'Romansh', + ], + [ + 'code' => 'ru', + 'name' => 'Russian', + ], + [ + 'code' => 'ru-ee', + 'name' => 'Russian (Estonia)', + ], + [ + 'code' => 'ru-lv', + 'name' => 'Russian (Latvia)', + ], + [ + 'code' => 'ru-lt', + 'name' => 'Russian (Lithuania)', + ], + [ + 'code' => 'ru@petr1708', + 'name' => 'Russian Petrine orthography', + ], + [ + 'code' => 'ru-RU', + 'name' => 'Russian (Russia)', + ], + [ + 'code' => 'sah', + 'name' => 'Sakha (Yakut)', + ], + [ + 'code' => 'sm', + 'name' => 'Samoan', + ], + [ + 'code' => 'sa', + 'name' => 'Sanskrit', + ], + [ + 'code' => 'sat', + 'name' => 'Santali', + ], + [ + 'code' => 'sc', + 'name' => 'Sardinian', + ], + [ + 'code' => 'sco', + 'name' => 'Scots', + ], + [ + 'code' => 'sr', + 'name' => 'Serbian', + ], + [ + 'code' => 'sr@Ijekavian', + 'name' => 'Serbian (Ijekavian)', + ], + [ + 'code' => 'sr@ijekavianlatin', + 'name' => 'Serbian (Ijekavian Latin)', + ], + [ + 'code' => 'sr@latin', + 'name' => 'Serbian (Latin)', + ], + [ + 'code' => 'sr-RS@latin', + 'name' => 'Serbian (Latin) (Serbia)', + ], + [ + 'code' => 'sr-RS', + 'name' => 'Serbian (Serbia)', + ], + [ + 'code' => 'sn', + 'name' => 'Shona', + ], + [ + 'code' => 'scn', + 'name' => 'Sicilian', + ], + [ + 'code' => 'szl', + 'name' => 'Silesian', + ], + [ + 'code' => 'sd', + 'name' => 'Sindhi', + ], + [ + 'code' => 'si', + 'name' => 'Sinhala', + ], + [ + 'code' => 'si-LK', + 'name' => 'Sinhala (Sri Lanka)', + ], + [ + 'code' => 'sk', + 'name' => 'Slovak', + ], + [ + 'code' => 'sk-SK', + 'name' => 'Slovak (Slovakia)', + ], + [ + 'code' => 'sl', + 'name' => 'Slovenian', + ], + [ + 'code' => 'sl-SI', + 'name' => 'Slovenian (Slovenia)', + ], + [ + 'code' => 'so', + 'name' => 'Somali', + ], + [ + 'code' => 'son', + 'name' => 'Songhay', + ], + [ + 'code' => 'st', + 'name' => 'Sotho, Southern', + ], + [ + 'code' => 'st-ZA', + 'name' => 'Sotho, Southern (South Africa)', + ], + [ + 'code' => 'sma', + 'name' => 'Southern Sami', + ], + [ + 'code' => 'es', + 'name' => 'Spanish', + ], + [ + 'code' => 'es-AR', + 'name' => 'Spanish (Argentina)', + ], + [ + 'code' => 'es-BO', + 'name' => 'Spanish (Bolivia)', + ], + [ + 'code' => 'es-CL', + 'name' => 'Spanish (Chile)', + ], + [ + 'code' => 'es-CO', + 'name' => 'Spanish (Colombia)', + ], + [ + 'code' => 'es-CR', + 'name' => 'Spanish (Costa Rica)', + ], + [ + 'code' => 'es-DO', + 'name' => 'Spanish (Dominican Republic)', + ], + [ + 'code' => 'es-EC', + 'name' => 'Spanish (Ecuador)', + ], + [ + 'code' => 'es-SV', + 'name' => 'Spanish (El Salvador)', + ], + [ + 'code' => 'es-GT', + 'name' => 'Spanish (Guatemala)', + ], + [ + 'code' => 'es-419', + 'name' => 'Spanish (Latin America)', + ], + [ + 'code' => 'es-MX', + 'name' => 'Spanish (Mexico)', + ], + [ + 'code' => 'es-NI', + 'name' => 'Spanish (Nicaragua)', + ], + [ + 'code' => 'es-PA', + 'name' => 'Spanish (Panama)', + ], + [ + 'code' => 'es-PY', + 'name' => 'Spanish (Paraguay)', + ], + [ + 'code' => 'es-PE', + 'name' => 'Spanish (Peru)', + ], + [ + 'code' => 'es-PR', + 'name' => 'Spanish (Puerto Rico)', + ], + [ + 'code' => 'es-ES', + 'name' => 'Spanish (Spain)', + ], + [ + 'code' => 'es-US', + 'name' => 'Spanish (United States)', + ], + [ + 'code' => 'es-UY', + 'name' => 'Spanish (Uruguay)', + ], + [ + 'code' => 'es-VE', + 'name' => 'Spanish (Venezuela)', + ], + [ + 'code' => 'su', + 'name' => 'Sundanese', + ], + [ + 'code' => 'sw', + 'name' => 'Swahili', + ], + [ + 'code' => 'sw-KE', + 'name' => 'Swahili (Kenya)', + ], + [ + 'code' => 'ss', + 'name' => 'Swati', + ], + [ + 'code' => 'sv', + 'name' => 'Swedish', + ], + [ + 'code' => 'sv-FI', + 'name' => 'Swedish (Finland)', + ], + [ + 'code' => 'sv-SE', + 'name' => 'Swedish (Sweden)', + ], + [ + 'code' => 'tl', + 'name' => 'Tagalog', + ], + [ + 'code' => 'tl-PH', + 'name' => 'Tagalog (Philippines)', + ], + [ + 'code' => 'tg', + 'name' => 'Tajik', + ], + [ + 'code' => 'tg-TJ', + 'name' => 'Tajik (Tajikistan)', + ], + [ + 'code' => 'tzl', + 'name' => 'Talossan', + ], + [ + 'code' => 'ta', + 'name' => 'Tamil', + ], + [ + 'code' => 'ta-IN', + 'name' => 'Tamil (India)', + ], + [ + 'code' => 'ta-LK', + 'name' => 'Tamil (Sri-Lanka)', + ], + [ + 'code' => 'tt', + 'name' => 'Tatar', + ], + [ + 'code' => 'te', + 'name' => 'Telugu', + ], + [ + 'code' => 'te-IN', + 'name' => 'Telugu (India)', + ], + [ + 'code' => 'tet', + 'name' => 'Tetum (Tetun)', + ], + [ + 'code' => 'th', + 'name' => 'Thai', + ], + [ + 'code' => 'th-TH', + 'name' => 'Thai (Thailand)', + ], + [ + 'code' => 'bo', + 'name' => 'Tibetan', + ], + [ + 'code' => 'bo-CN', + 'name' => 'Tibetan (China)', + ], + [ + 'code' => 'ti', + 'name' => 'Tigrinya', + ], + [ + 'code' => 'to', + 'name' => 'Tongan', + ], + [ + 'code' => 'ts', + 'name' => 'Tsonga', + ], + [ + 'code' => 'tn', + 'name' => 'Tswana', + ], + [ + 'code' => 'tr', + 'name' => 'Turkish', + ], + [ + 'code' => 'tr-TR', + 'name' => 'Turkish (Turkey)', + ], + [ + 'code' => 'tk', + 'name' => 'Turkmen', + ], + [ + 'code' => 'tk-TM', + 'name' => 'Turkmen (Turkmenistan)', + ], + [ + 'code' => 'udm', + 'name' => 'Udmurt', + ], + [ + 'code' => 'ug', + 'name' => 'Uighur', + ], + [ + 'code' => 'ug@Arab', + 'name' => 'Uighur (Arabic)', + ], + [ + 'code' => 'ug@Cyrl', + 'name' => 'Uighur (Cyrillic)', + ], + [ + 'code' => 'ug@Latin', + 'name' => 'Uighur (Latin)', + ], + [ + 'code' => 'uk', + 'name' => 'Ukrainian', + ], + [ + 'code' => 'uk-UA', + 'name' => 'Ukrainian (Ukraine)', + ], + [ + 'code' => 'vmf', + 'name' => 'Upper Franconian', + ], + [ + 'code' => 'hsb', + 'name' => 'Upper Sorbian', + ], + [ + 'code' => 'ur', + 'name' => 'Urdu', + ], + [ + 'code' => 'ur-PK', + 'name' => 'Urdu (Pakistan)', + ], + [ + 'code' => 'uz', + 'name' => 'Uzbek', + ], + [ + 'code' => 'uz@Arab', + 'name' => 'Uzbek (Arabic)', + ], + [ + 'code' => 'uz@Cyrl', + 'name' => 'Uzbek (Cyrillic)', + ], + [ + 'code' => 'uz@Latn', + 'name' => 'Uzbek (Latin)', + ], + [ + 'code' => 'uz-UZ', + 'name' => 'Uzbek (Uzbekistan)', + ], + [ + 'code' => 've', + 'name' => 'Venda', + ], + [ + 'code' => 'vec', + 'name' => 'Venetian', + ], + [ + 'code' => 'vi', + 'name' => 'Vietnamese', + ], + [ + 'code' => 'vi-VN', + 'name' => 'Vietnamese (Viet Nam)', + ], + [ + 'code' => 'vls', + 'name' => 'Vlaams', + ], + [ + 'code' => 'wa', + 'name' => 'Walloon', + ], + [ + 'code' => 'war', + 'name' => 'Wáray-Wáray', + ], + [ + 'code' => 'cy', + 'name' => 'Welsh', + ], + [ + 'code' => 'cy-GB', + 'name' => 'Welsh (United Kingdom)', + ], + [ + 'code' => 'fy', + 'name' => 'Western Frisian', + ], + [ + 'code' => 'fy-NL', + 'name' => 'Western Frisian (Netherlands)', + ], + [ + 'code' => 'wo', + 'name' => 'Wolof', + ], + [ + 'code' => 'wo-SN', + 'name' => 'Wolof (Senegal)', + ], + [ + 'code' => 'xh', + 'name' => 'Xhosa', + ], + [ + 'code' => 'yi', + 'name' => 'Yiddish', + ], + [ + 'code' => 'yo', + 'name' => 'Yoruba', + ], + [ + 'code' => 'zu', + 'name' => 'Zulu', + ], + [ + 'code' => 'zu-ZA', + 'name' => 'Zulu (South Africa)', + ], + ]; + return response()->json(['results' => $languages]); + }//end index() +}//end class diff --git a/v4/Http/Controllers/README.md b/v4/Http/Controllers/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/v4/Http/Controllers/SurveyController.php b/v4/Http/Controllers/SurveyController.php new file mode 100644 index 0000000000..379f4d5777 --- /dev/null +++ b/v4/Http/Controllers/SurveyController.php @@ -0,0 +1,355 @@ +find($id); + if (!$survey) { + return response()->json( + [ + 'errors' => [ + 'error' => 404, + 'message' => 'Not found', + ], + ], + 404 + ); + } + + return new SurveyResource($survey); + }//end show() + + + /** + * Display the specified resource. + * + * @return SurveyCollection + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function index() + { + return new SurveyCollection(Survey::all()); + }//end index() + + /** + * Display the specified resource. + * + * @TODO transactions =) + * @param Request $request + * @return SurveyResource + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function store(Request $request) + { + $authorizer = service('authorizer.form'); + // if there's no user the guards will kick them off already, but if there + // is one we need to check the authorizer to ensure we don't let + // users without admin perms create forms etc + // this is an unfortunate problem with using an old version of lumen + // that doesn't let me do guest user checks without adding more risk. + $user = $authorizer->getUser(); + if ($user) { + $this->authorize('store', Survey::class); + } + + $this->validate($request, Survey::getRules(), Survey::validationMessages()); + $survey = Survey::create( + array_merge( + $request->input(), + [ + 'updated' => time(), + 'created' => time(), + ] + ) + ); + $this->saveTranslations($request->input('translations'), $survey->id, 'survey'); + if ($request->input('tasks')) { + foreach ($request->input('tasks') as $stage) { + $stage_model = $survey->tasks()->create( + array_merge( + $stage, + [ + 'updated' => time(), + 'created' => time(), + ] + ) + ); + $this->saveTranslations(($stage['translations'] ?? []), $stage_model->id, 'task'); + foreach ($stage['fields'] as $attribute) { + $uuid = Uuid::uuid4(); + $attribute['key'] = $uuid->toString(); + $field_model = $stage_model->fields()->create( + array_merge( + $attribute, + [ + 'updated' => time(), + 'created' => time(), + ] + ) + ); + $this->saveTranslations(($attribute['translations'] ?? []), $field_model->id, 'field'); + } + }//end foreach + }//end if + + return new SurveyResource($survey); + }//end store() + + + /** + * @param $input + * @param $translatable_id + * @param $type + * @return boolean + */ + private function saveTranslations($input, int $translatable_id, string $type) + { + if (!is_array($input)) { + return true; + } + + foreach ($input as $language => $translations) { + foreach ($translations as $key => $translated) { + if (is_array($translated)) { + $translated = json_encode($translated); + } + + Translation::create( + [ + 'translatable_type' => $type, + 'translatable_id' => $translatable_id, + 'translated_key' => $key, + 'translation' => $translated, + 'language' => $language, + ] + ); + } + } + }//end saveTranslations() + + + /** + * Display the specified resource. + * + * @TODO transactions =) + * @param integer $id + * @param Request $request + * @return mixed + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function update(int $id, Request $request) + { + $survey = Survey::find($id); + if (!$survey) { + return response()->json( + [ + 'errors' => [ + 'error' => 404, + 'message' => 'Not found', + ], + ], + 404 + ); + } + + $this->authorize('update', $survey); + if (!$survey) { + return response()->json( + [ + 'errors' => [ + 'error' => 404, + 'message' => 'Not found', + ], + ], + 404 + ); + } + + $this->validate($request, Survey::getRules(), Survey::validationMessages()); + $survey->update( + array_merge( + $request->input(), + ['updated' => time()] + ) + ); + $this->updateTranslations($request->input('translations'), $survey->id, 'survey'); + $this->updateTasks(($request->input('tasks') ?? []), $survey); + $survey->load('tasks'); + + return new SurveyResource($survey); + }//end update() + + + /** + * @param $input + * @param $translatable_id + * @param $type + * @return boolean + */ + private function updateTranslations($input, int $translatable_id, string $type) + { + if (!is_array($input)) { + return true; + } + + Translation::where('translatable_id', $translatable_id)->where('translatable_type', $type)->delete(); + foreach ($input as $language => $translations) { + foreach ($translations as $key => $translated) { + if (is_array($translated)) { + $translated = json_encode($translated); + } + + Translation::create( + [ + 'translatable_type' => $type, + 'translatable_id' => $translatable_id, + 'translated_key' => $key, + 'translation' => $translated, + 'language' => $language, + ] + ); + } + } + }//end updateTranslations() + + + /** + * @param array $input_tasks + * @param Survey $survey + */ + private function updateTasks(array $input_tasks, Survey $survey) + { + $added_tasks = []; + foreach ($input_tasks as $stage) { + if (isset($stage['id'])) { + $stage_model = $survey->tasks->find($stage['id']); + if (!$stage_model) { + continue; + } + + $stage_model->update($stage); + $stage_model = Stage::find($stage['id']); + } else { + $stage_model = $survey->tasks()->create( + array_merge( + $stage, + ['updated' => time()] + ) + ); + $added_tasks[] = $stage_model->id; + } + + $this->updateTranslations(($stage['translations'] ?? []), $stage_model->id, 'task'); + $this->updateFields(($stage['fields'] ?? []), $stage_model); + }//end foreach + + $input_tasks_collection = new Collection($input_tasks); + $survey->load('tasks'); + + $tasks_to_delete = $survey->tasks->whereNotIn( + 'id', + array_merge($added_tasks, $input_tasks_collection->groupBy('id')->keys()->toArray()) + ); + foreach ($tasks_to_delete as $task_to_delete) { + Stage::where('id', $task_to_delete->id)->delete(); + } + }//end updateTasks() + + + /** + * @param array $input_fields + * @param Survey $survey + * @param Stage $stage + */ + private function updateFields(array $input_fields, Stage $stage) + { + $added_fields = []; + foreach ($input_fields as $field) { + if (isset($field['id'])) { + $field_model = $stage->fields->find($field['id']); + if (!$field_model) { + continue; + } + + $field_model->update($field); + $field_model = Attribute::find($field['id']); + } else { + $uuid = Uuid::uuid4(); + $field_model = $stage->fields()->create( + array_merge( + $field, + [ + 'updated' => time(), + 'key' => $uuid->toString(), + ] + ) + ); + $added_fields[] = $field_model->id; + }//end if + + $this->updateTranslations(($field['translations'] ?? []), $field_model->id, 'field'); + }//end foreach + + $input_fields_collection = new Collection($input_fields); + $stage->load('fields'); + + $fields_to_delete = $stage->fields->whereNotIn( + 'id', + array_merge($added_fields, $input_fields_collection->groupBy('id')->keys()->toArray()) + ); + foreach ($fields_to_delete as $field_to_delete) { + Attribute::where('id', $field_to_delete->id)->delete(); + } + }//end updateFields() + + + /** + * @param integer $id + */ + public function delete(int $id, Request $request) + { + $survey = Survey::find($id); + $this->authorize('delete', $survey); + $task_ids = $survey->tasks->modelKeys(); + + $field_ids = $survey->tasks->map(function ($task, $key) use (&$field_ids) { + return $task->fields->modelKeys(); + })->flatten(); + + Translation::whereIn('translatable_id', $task_ids) + ->where('translatable_type', 'task') + ->delete(); + + Translation::whereIn('translatable_id', $field_ids) + ->where('translatable_type', 'field') + ->delete(); + + $survey->translations()->delete(); + $survey->delete(); + + return response()->json(['result' => ['deleted' => $id]]); + }//end delete() +}//end class diff --git a/v4/Http/Controllers/V4Controller.php b/v4/Http/Controllers/V4Controller.php new file mode 100644 index 0000000000..1c9091b37d --- /dev/null +++ b/v4/Http/Controllers/V4Controller.php @@ -0,0 +1,13 @@ +collection; + } +} diff --git a/v4/Http/Resources/CategoryResource.php b/v4/Http/Resources/CategoryResource.php new file mode 100644 index 0000000000..c6db9a4743 --- /dev/null +++ b/v4/Http/Resources/CategoryResource.php @@ -0,0 +1,38 @@ + $this->id, + 'parent_id' => $this->parent_id, + 'tag' => $this->tag, + 'slug' => $this->slug, + 'type' => $this->type, + 'color' => $this->color, + 'icon' => $this->icon, + 'description' => $this->description, + 'role' => $this->role, + 'priority' => $this->priority, + 'children' => $this->children, + 'parent' => $this->parent, + 'translations' => new TranslationCollection($this->translations), + 'enabled_languages' => [ + 'default'=> $this->base_language, + 'available' => $this->translations->groupBy('language')->keys() + ] + ]; + } +} diff --git a/v4/Http/Resources/FieldCollection.php b/v4/Http/Resources/FieldCollection.php new file mode 100644 index 0000000000..7f69a4db15 --- /dev/null +++ b/v4/Http/Resources/FieldCollection.php @@ -0,0 +1,26 @@ +collection; + } +} diff --git a/v4/Http/Resources/FieldResource.php b/v4/Http/Resources/FieldResource.php new file mode 100644 index 0000000000..d1e5556ba9 --- /dev/null +++ b/v4/Http/Resources/FieldResource.php @@ -0,0 +1,36 @@ + $this->id, + 'key' => $this->key, + 'label' => $this->label, + 'instructions' => $this->instructions, + 'input' => $this->input, + 'type' => $this->type, + 'required' => $this->required, + 'default' => $this->default, + 'priority' => $this->priority, + 'options' => $this->options, + 'cardinality' => $this->cardinality, + 'config' => $this->config, + 'response_private' => $this->response_private, + 'form_stage_id' => $this->form_stage_id, + 'translations' => new TranslationCollection($this->translations), + ]; + } +} diff --git a/v4/Http/Resources/SurveyCollection.php b/v4/Http/Resources/SurveyCollection.php new file mode 100644 index 0000000000..7a14a016d5 --- /dev/null +++ b/v4/Http/Resources/SurveyCollection.php @@ -0,0 +1,28 @@ +collection; + } +} diff --git a/v4/Http/Resources/SurveyResource.php b/v4/Http/Resources/SurveyResource.php new file mode 100644 index 0000000000..f2954d3079 --- /dev/null +++ b/v4/Http/Resources/SurveyResource.php @@ -0,0 +1,41 @@ + $this->id, + 'name' => $this->name, + 'parent_id' => $this->parent_id, + 'description' => $this->description, + 'type' => $this->type, + 'disabled' => $this->disabled, + 'require_approval' => $this->require_approval, + 'everyone_can_create' => $this->everyone_can_create, + 'color' => $this->color, + 'hide_author' => $this->hide_author, + 'hide_time' => $this->hide_time, + 'hide_location' => $this->hide_location, + 'targeted_survey' => $this->targeted_survey, + 'translations' => new TranslationCollection($this->translations), + 'tasks' => new TaskCollection($this->tasks), + 'can_create' => $this->can_create, + 'enabled_languages' => [ + 'default'=> $this->base_language, + 'available' => $this->translations->groupBy('language')->keys() + ] + ]; + } +} diff --git a/v4/Http/Resources/TaskCollection.php b/v4/Http/Resources/TaskCollection.php new file mode 100644 index 0000000000..49b2336786 --- /dev/null +++ b/v4/Http/Resources/TaskCollection.php @@ -0,0 +1,26 @@ +collection; + } +} diff --git a/v4/Http/Resources/TaskResource.php b/v4/Http/Resources/TaskResource.php new file mode 100644 index 0000000000..2f96b40b62 --- /dev/null +++ b/v4/Http/Resources/TaskResource.php @@ -0,0 +1,32 @@ + $this->id, + 'form_id' => $this->form_id, + 'label' => $this->label, + 'priority' => $this->priority, + 'required' => $this->required, + 'type' => $this->type, + 'description' => $this->description, + 'show_when_published' => $this->show_when_published, + 'task_is_internal_only' => $this->task_is_internal_only, + 'fields' => new FieldCollection($this->fields), + 'translations' => new TranslationCollection($this->translations) + ]; + } +} diff --git a/v4/Http/Resources/TranslationCollection.php b/v4/Http/Resources/TranslationCollection.php new file mode 100644 index 0000000000..3f52ab58e6 --- /dev/null +++ b/v4/Http/Resources/TranslationCollection.php @@ -0,0 +1,41 @@ +collection->mapToGroups(function ($item, $key) { + if ($item->translated_key === 'options' && + $item->translatable_type==='field' + ) { + $item->translation = json_decode($item->translation); + } + return [$item->language => $item]; + }); + $combined = $grouped->map(function ($item, $key) { + return $item->mapWithKeys(function ($i) { + return [$i->translated_key => $i->translation ]; + }); + }); + return $combined; + } +} diff --git a/v4/Http/Resources/TranslationResource.php b/v4/Http/Resources/TranslationResource.php new file mode 100644 index 0000000000..7f04291c8e --- /dev/null +++ b/v4/Http/Resources/TranslationResource.php @@ -0,0 +1,24 @@ + $this->translated_key, + 'translation' => $this->translation, + 'language' => $this->language + ]; + } +} diff --git a/v4/Models/Attribute.php b/v4/Models/Attribute.php new file mode 100644 index 0000000000..b645eefc13 --- /dev/null +++ b/v4/Models/Attribute.php @@ -0,0 +1,59 @@ + 'json', + 'options' => 'json', + ]; + protected $hidden = ['icon']; + + public function stage() + { + return $this->belongsTo('v4\Models\Stage', 'form_stage_id'); + } + + + /** + * Get the attribute's translation. + */ + public function translations() + { + return $this->morphMany('v4\Models\Translation', 'translatable'); + } +} diff --git a/v4/Models/Category.php b/v4/Models/Category.php new file mode 100644 index 0000000000..45e1d80c41 --- /dev/null +++ b/v4/Models/Category.php @@ -0,0 +1,371 @@ + 'category' + ]; + + protected $casts = [ + 'role' => 'json' + ]; + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public static function validationMessages() + { + return [ + 'parent_id.exists' => trans( + 'validation.exists', + ['field' => trans('fields.parent_id')] + ), + 'tag.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.tag')] + ), + 'tag.unique' => trans( + 'validation.unique', + ['field' => trans('fields.tag')] + ), + 'tag.min' => trans( + 'validation.min_length', + [ + 'param2' => 2, + 'field' => trans('fields.tag'), + ] + ), + 'tag.max' => trans( + 'validation.max_length', + [ + 'param2' => 255, + 'field' => trans('fields.tag'), + ] + ), + 'tag.regex' => trans( + 'validation.regex', + ['field' => trans('fields.tag')] + ), + 'slug.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.slug')] + ), + 'slug.min' => trans( + 'validation.min_length', + [ + 'param2' => 2, + 'field' => trans('fields.slug'), + ] + ), + 'slug.unique' => trans( + 'validation.unique', + ['field' => trans('fields.slug')] + ), + 'type.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.type')] + ), + 'type.in' => trans( + 'validation.in_array', + ['field' => trans('fields.type')] + ), + 'description.regex' => trans( + 'validation.regex', + ['field' => trans('fields.description')] + ), + 'icon.regex' => trans( + 'validation.regex', + ['field' => trans('fields.icon')] + ), + 'priority.numeric' => trans( + 'validation.numeric', + ['field' => trans('fields.priority')] + ), + ]; + }//end translations() + + /** + * Return all validation rules + * @return array + */ + public function getRules() + { + return [ + 'parent_id' => 'sometimes|exists:tags,id', + 'tag' => [ + 'required', + 'min:2', + 'max:255', + 'regex:/^[\pL\pN\pP ]++$/uD', + Rule::unique('tags')->ignore($this->id) + ], + 'slug' => [ + 'required', + 'min:2', + Rule::unique('tags')->ignore($this->id) + ], + 'type' => [ + 'required', + Rule::in([ + 'category', + 'status' + ]) + // 'min:2', + // 'max:255', + // 'regex:'.LegacyValidator::REGEX_STANDARD_TEXT, + ], + 'description' => [ + 'regex:/^[\pL\pN\pP ]++$/uD' + // 'min:2', + // 'max:255', + // 'regex:'.LegacyValidator::REGEX_STANDARD_TEXT, + ], + // 'color' => [ + // ['color'], + // ] + 'icon' => [ + 'regex:/^[\pL\s\_\-]++$/uD' + ], + 'priority' => [ + 'numeric' + ], + // 'role' => [ + // [[$this->role_repo, 'exists'], [':value']], + // [[$this->repo, 'isRoleValid'], [':validation', ':fulldata']] + // ] + ]; + }//end validationMessages() + + /** + * Get the category's translation. + */ + public function translations() + { + return $this->morphMany('v4\Models\Translation', 'translatable'); + }//end getRules() + + public function parent() + { + return $this->hasOne('v4\Models\Category', 'id', 'parent_id'); + } + public function children() + { + return $this->hasMany('v4\Models\Category', 'parent_id', 'id'); + } + + /** + * Scope helper to only pull tags we are allowed to get from the db + * @param $query + * @return mixed + */ + public function scopeAllowed($query) + { + /** + * If no roles are selected, the Tag is considered + * completely public. + */ + $authorizer = service('authorizer.tag'); + $user = $authorizer->getUser(); + + if ($user->role) { + // couldn't think of a better way to deal with our JSON-but-not-json fields + // get categories that are available for users with this role or NULL role + // taking care NOT to bring any child categories that belong + // to parents with other role restrictions + $q = $query->where(function ($query) use ($user) { + return $query + ->whereNull('role') + ->orWhere('role', 'LIKE', '%\"' . $user->role . '\"%') + ; + }); + $q->where(function ($query) use ($user) { + return $query + ->whereNotIn('parent_id', function ($query) use ($user) { + $query + ->select('id') + ->from('tags') + ->where('role', 'NOT LIKE', '%\"' . $user->role . '\"%') + ->whereNull('parent_id'); + }) + ->orWhereNull('parent_id'); + }); + // generates a query like like this + // select * from `tags` where (`role` is null or `role` LIKE ?) + // AND (`parent_id` not in + // ( + // select `id` from `tags` where `role` NOT LIKE ? and `parent_id` is null + // ) + // or `parent_id` is null) + return $q; + } + // get categories that are available for non logged in users + // taking care NOT to bring any child categories that belong + // to parents with admin/user/other role restrictions + $q = $query->whereNull('role')->where(function ($query) use ($user) { + return $query + ->whereNotIn('parent_id', function ($query) use ($user) { + $query + ->select('id') + ->from('tags') + ->whereNotNull('role') + ->whereNull('parent_id'); + }) + ->orWhereNull('parent_id'); + }); + // generates a query like this: + // select * from `tags` where `role` is null + // AND (`parent_id` not in + // ( + // select `id` from `tags` where `role` is not null and `parent_id` is null + // ) + // or `parent_id` is null) + + return $q; + } + + /** + * Get the category's color format + * + * @param string $value + * @return void + */ + public function getColorAttribute($value) + { + return $value ? "#" . $value : $value; + } + /** + * Set the category's color format + * + * @param string $value + * @return void + */ + public function setColorAttribute($value) + { + if (isset($value)) { + $this->attributes['color'] = ltrim($value, '#'); + } + } + + /** + * Get the category's slug + * + * @param string $value + * @return void + */ + public function getSlugAttribute($value) + { + return $value; + } + /** + * Set the category's slug format + * + * @param string $value + * @return void + */ + public function setSlugAttribute($value) + { + if (isset($value) && (!isset($this->attributes['slug']))) { + $value = self::makeSlug($value); + $this->attributes['slug'] = $value; + } + } + public static function makeSlug($value) + { + // Make it lowercase + $value = mb_strtolower($value, 'utf-8'); + + // .. anything not the separator, letters, numbers or whitespace is replaced + $value = preg_replace('/[^\pL\pN\-\s]+/u', '', $value); + + // .. replace whitespace and multiple separator chars with a single separator + $value = preg_replace('/[\-\s]+/u', '-', $value); + + // ... and replace spaces with hypens + $value = str_replace(' ', '-', $value); + return $value; + } + + public function validate($data) + { + $v = Validator::make($data, $this->getRules(), self::validationMessages()); + // check for failure + if (!$v->fails()) { + return true; + } + // set errors and return false + $this->errors = $v->errors(); + return false; + } + + public function errors() + { + return $this->errors; + } +}//end class diff --git a/v4/Models/Stage.php b/v4/Models/Stage.php new file mode 100644 index 0000000000..17185bb2fa --- /dev/null +++ b/v4/Models/Stage.php @@ -0,0 +1,54 @@ +hasMany('v4\Models\Attribute', 'form_stage_id'); + } + + public function survey() + { + return $this->belongsTo('v4\Models\Survey', 'form_id'); + } + + /** + * Get the stage's translation. + */ + public function translations() + { + return $this->morphMany('v4\Models\Translation', 'translatable'); + } +} diff --git a/v4/Models/Survey.php b/v4/Models/Survey.php new file mode 100644 index 0000000000..84788b9885 --- /dev/null +++ b/v4/Models/Survey.php @@ -0,0 +1,444 @@ + 'report', + 'require_approval' => true, + 'everyone_can_create' => true, + 'hide_author' => false, + 'hide_time' => false, + 'disabled' => false, + 'hide_location' => false, + 'targeted_survey' => false, + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'everyone_can_create' => 'boolean', + 'hide_author' => 'boolean', + 'require_approval' => 'boolean', + 'disabled' => 'boolean', + ]; + + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public static function validationMessages() + { + return [ + 'name.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.name')] + ), + 'name.min' => trans( + 'validation.min_length', + [ + 'param2' => 2, + 'field' => trans('fields.name'), + ] + ), + 'name.max' => trans( + 'validation.max_length', + [ + 'param2' => 255, + 'field' => trans('fields.name'), + ] + ), + 'name.regex' => trans( + 'validation.regex', + ['field' => trans('fields.name')] + ), + // @TODO Add description.string + // @TODO Add color.string + 'disabled.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.disabled')] + ), + 'everyone_can_create.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.everyone_can_create')] + ), + 'hide_author.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.hide_author')] + ), + 'hide_location.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.hide_location')] + ), + 'hide_time.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.hide_time')] + ), + 'targeted_survey.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.targeted_survey')] + ), + 'tasks.*.label.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.tasks.label')] + ), + 'tasks.*.label.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.tasks.label')] + ), + 'tasks.*.type.in' => trans( + 'validation.in_array', + ['field' => trans('fields.tasks.type')] + ), + 'tasks.*.priority.numeric' => trans( + 'validation.numeric', + ['field' => trans('fields.tasks.priority')] + ), + 'tasks.*.icon.alpha' => trans( + 'validation.alpha', + ['field' => trans('fields.tasks.icon')] + ), + 'tasks.*.fields.*.label.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.tasks.fields.label')] + ), + 'tasks.*.fields.*.label.max' => trans( + 'validation.max_length', + [ + 'param2' => 150, + 'field' => trans('fields.tasks.fields.label'), + ] + ), + 'tasks.*.fields.*.key.alpha_dash' => trans( + 'validation.alpha_dash', + ['field' => trans('fields.tasks.fields.key')] + ), + 'tasks.*.fields.*.key.max' => trans( + 'validation.max_length', + [ + 'param2' => 150, + 'field' => trans('fields.tasks.fields.key'), + ] + ), + 'tasks.*.fields.*.input.required' => trans( + 'validation.not_empty', + ['param2' => trans('fields.tasks.fields.input')] + ), + 'tasks.*.fields.*.input.in' => trans( + 'validation.in_array', + ['field' => trans('fields.tasks.fields.input')] + ), + 'tasks.*.fields.*.type.required' => trans( + 'validation.not_empty', + ['field' => trans('fields.tasks.fields.type')] + ), + 'tasks.*.fields.*.type.in' => trans( + 'validation.in_array', + ['field' => trans('fields.tasks.fields.type')] + ), + 'tasks.*.fields.*.priority.numeric' => trans( + 'validation.numeric', + ['field' => trans('fields.tasks.fields.priority')] + ), + 'tasks.*.fields.*.cardinality.numeric' => trans( + 'validation.numeric', + ['field' => trans('fields.tasks.fields.cardinality')] + ), + 'tasks.*.fields.*.response_private.boolean' => trans( + 'validation.regex', + ['field' => trans('fields.tasks.fields.response_private')] + ), + // 'tasks.*.fields.*.response_private' => [ + // @TODO add this custom validator for canMakePrivate + // [[$this, 'canMakePrivate'], [':value', $type]] + // ] + ]; + }//end validationMessages() + + + /** + * Return all validation rules + * + * @return array + */ + protected static function getRules() + { + return [ + 'name' => [ + 'required', + 'min:2', + 'max:255', + 'regex:'.LegacyValidator::REGEX_STANDARD_TEXT, + ], + 'description' => [ + 'string', + 'nullable', + ], + // @TODO find out where this color validator is implemented + // [['color']], + 'color' => [ + 'string', + 'nullable', + ], + 'disabled' => ['boolean'], + 'everyone_can_create' => ['boolean'], + 'hide_author' => ['boolean'], + 'hide_location' => ['boolean'], + 'hide_time' => ['boolean'], + 'targeted_survey' => ['boolean'], + 'tasks.*.label' => [ + 'required', + 'regex:'.LegacyValidator::REGEX_STANDARD_TEXT, + ], + 'tasks.*.type' => [Rule::in( + [ + 'post', + 'task', + ] + )], + 'tasks.*.priority' => ['numeric'], + 'tasks.*.icon' => ['alpha'], + 'tasks.*.fields.*.label' => [ + 'required', + 'max:150', + ], + 'tasks.*.fields.*.key' => [ + 'max:150', + 'alpha_dash', + // @TODO: add this validation for keys + // [[$this->repo, 'isKeyAvailable'], [':value']] + ], + 'tasks.*.fields.*.input' => [ + 'required', + Rule::in( + [ + 'text', + 'textarea', + 'select', + 'radio', + 'checkbox', + 'checkboxes', + 'date', + 'datetime', + 'location', + 'number', + 'relation', + 'upload', + 'video', + 'markdown', + 'tags', + ] + ), + ], + 'tasks.*.fields.*.type' => [ + 'required', + Rule::in( + [ + 'decimal', + 'int', + 'geometry', + 'text', + 'varchar', + 'markdown', + 'point', + 'datetime', + 'link', + 'relation', + 'media', + 'title', + 'description', + 'tags', + ] + ), + // @TODO: add this validation for duplicates in type? + // [[$this, 'checkForDuplicates'], [':validation', ':value']], + ], + 'tasks.*.fields.*.type' => ['string'], + 'tasks.*.fields.*.priority' => ['numeric'], + 'tasks.*.fields.*.cardinality' => ['numeric'], + 'tasks.*.fields.*.response_private' => [ + 'boolean' + // @TODO add this custom validator for canMakePrivate + // [[$this, 'canMakePrivate'], [':value', $type]] + ], + // @NOTE: checkPostTypeLimit is not used here. + // Before merge, validate with Angela if we + // should be removing that arbitrary limit since it's pretty rare + // for it to be needed + ]; + }//end getRules() + + + /** + * This is what makes can_create possible + * + * @return mixed + */ + public function getCanCreateAttribute() + { + $can_create = $this->getCanCreateRoles($this->id); + return $can_create['roles']; + }//end getCanCreateAttribute() + + + private function getCanCreateRoles($form_id) + { + /* + * @NOTE: to lower changes of a regression I'm using some helpers from + * repositories and traits we already have + * @NOTE: during origami and later stages of sunny buffers, we will fold this + * all together in more performant and friendly ways + */ + $form_repo = service('repository.form'); + return $form_repo->getRolesThatCanCreatePosts($form_id); + }//end getCanCreateRoles() + + + /** + * We check for relationship permissions here, to avoid hydrating anything that should not be hydrated. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function tasks() + { + $authorizer = service('authorizer.form'); + $user = $authorizer->getUser(); + // NOTE: this acl->hasPermission check is all `canUserEditForm` does, so we're doing that directly + // to avoid an hydration issue with InteractsWithFormPermissions + if ($authorizer->acl->hasPermission($user, Permission::MANAGE_POSTS)) { + // if this permission is set we can go ahead and hydrate all the stages + return $this->hasMany('v4\Models\Stage', 'form_id'); + } + + return $this->hasMany( + 'v4\Models\Stage', + 'form_id' + ) + ->where('form_stages.show_when_published', '=', '1') + ->where('form_stages.task_is_internal_only', '=', '0'); + }//end tasks() + + + /** + * Get the survey's translation. + */ + public function translations() + { + return $this->morphMany('v4\Models\Translation', 'translatable'); + }//end translations() + + /** + * Set the user's first name. + * + * @param string $value + * @return void + */ + public function getColorAttribute($value) + { + return $value ? "#" . $value : $value; + } + /** + * Set the user's first name. + * + * @param string $value + * @return void + */ + public function setColorAttribute($value) + { + if (isset($value)) { + $this->attributes['color'] = ltrim($value, '#'); + } + } +}//end class diff --git a/v4/Models/Translation.php b/v4/Models/Translation.php new file mode 100644 index 0000000000..8c6ff503b8 --- /dev/null +++ b/v4/Models/Translation.php @@ -0,0 +1,38 @@ +morphTo(); + } +} diff --git a/v4/Policies/CategoryPolicy.php b/v4/Policies/CategoryPolicy.php new file mode 100644 index 0000000000..8b357637fe --- /dev/null +++ b/v4/Policies/CategoryPolicy.php @@ -0,0 +1,137 @@ +isAllowed($empty_tag, 'search'); + } + + /** + * + * @param GenericUser $user + * @param Category $category + * @return bool + */ + public function show(User $user, Category $category) + { + $tag = new Entity\Tag($category->toArray()); + return $this->isAllowed($tag, 'read'); + } + + /** + * + * @param GenericUser $user + * @param Category $category + * @return bool + */ + public function delete(User $user, Category $category) + { + $tag = new Entity\Tag($category->toArray()); + return $this->isAllowed($tag, 'delete'); + } + /** + * @param Category $category + * @return bool + */ + public function update(User $user, Category $category) + { + // we convert to a form entity to be able to continue using the old authorizers and classes. + $tag = new Entity\Tag($category->toArray()); + return $this->isAllowed($tag, 'update'); + } + + + /** + * @param Survey $survey + * @return bool + */ + public function store() + { + // we convert to a form entity to be able to continue using the old authorizers and classes. + $tag = new Entity\Tag(); + return $this->isAllowed($tag, 'create'); + } + /** + * @param $entity + * @param string $privilege + * @return bool + */ + public function isAllowed($entity, $privilege) + { + $authorizer = service('authorizer.tag'); + + // These checks are run within the user context. + $user = $authorizer->getUser(); + + // Only logged in users have access if the deployment is private + if (!$this->canAccessDeployment($user)) { + return false; + } + + // First check whether there is a role with the right permissions + if ($authorizer->acl->hasPermission($user, Permission::MANAGE_SETTINGS)) { + return true; + } + + // Then we check if a user has the 'admin' role. If they do they're + // allowed access to everything (all entities and all privileges) + if ($this->isUserAdmin($user)) { + return true; + } + + // isAllowParent is usually checked here in v3, but we do + // it at the eloquent level instead + + // isUserOfRole is usually checked here in v3, but we do + // it at the eloquent level instead + + if ($privilege === 'search') { + return true; + } + + // If no other access checks succeed, we default to denying access + return false; + } +} diff --git a/v4/Policies/SurveyPolicy.php b/v4/Policies/SurveyPolicy.php new file mode 100644 index 0000000000..c6b8b50b0e --- /dev/null +++ b/v4/Policies/SurveyPolicy.php @@ -0,0 +1,153 @@ +isAllowed($empty_form, 'search'); + } + + /** + * + * @param GenericUser $user + * @param Survey $survey + * @return bool + */ + public function show(User $user, Survey $survey) + { + $form = new Entity\Form($survey->toArray()); + return $this->isAllowed($form, 'read'); + } + + /** + * + * @param GenericUser $user + * @param Survey $survey + * @return bool + */ + public function delete(User $user, Survey $survey) + { + $form = new Entity\Form($survey->toArray()); + return $this->isAllowed($form, 'delete'); + } + /** + * @param Survey $survey + * @return bool + */ + public function update(User $user, Survey $survey) + { + // we convert to a form entity to be able to continue using the old authorizers and classes. + $form = new Entity\Form($survey->toArray()); + return $this->isAllowed($form, 'update'); + } + + + /** + * @param Survey $survey + * @return bool + */ + public function store() + { + // we convert to a form entity to be able to continue using the old authorizers and classes. + $form = new Entity\Form(); + return $this->isAllowed($form, 'create'); + } + /** + * @param $entity + * @param string $privilege + * @return bool + */ + public function isAllowed($entity, $privilege) + { + $authorizer = service('authorizer.form'); + + // These checks are run within the user context. + $user = $authorizer->getUser(); + + // Only logged in users have access if the deployment is private + if (!$this->canAccessDeployment($user)) { + return false; + } + + // Allow role with the right permissions + if ($authorizer->acl->hasPermission($user, Permission::MANAGE_SETTINGS)) { + return true; + } + + if ($this->isUserAdmin($user)) { + return true; + } + + // Before /v4 we would check if the user has access to a parent form. This check has to be run + // before public access is granted... but parent forms aren't a thing + // @IMPORTANT : parent forms are not a thing, they don't do anything, they don't exist. + // I leave this here because it can be confusing otherwise. + // if (!$this->isAllowedParent($entity, $privilege, $user)) { + // return false; + // } + + // If a form is not disabled, then *anyone* can view it. + if ($privilege === 'read' && !$this->isFormDisabled($entity)) { + return true; + } + + // All users are allowed to search forms. + // @TODO should only do 'search' here. Do 'read' above in the isFormDisabled check + if ($privilege === 'search') { + return true; + } + + return false; + } + + /** + * Check if a form is disabled. + * @param Entity $entity + * @return Boolean + */ + protected function isFormDisabled(Entity\Form $entity) + { + return (bool) $entity->disabled; + } +} diff --git a/v4/Providers/MorphServiceProvider.php b/v4/Providers/MorphServiceProvider.php new file mode 100644 index 0000000000..ba6a98418a --- /dev/null +++ b/v4/Providers/MorphServiceProvider.php @@ -0,0 +1,20 @@ + 'v4\Models\Survey', + 'task' => 'v4\Models\Stage', + 'field' => 'v4\Models\Attribute', + 'category' => 'v4\Models\Category' + ]); + } +} diff --git a/v4/README.md b/v4/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/v4/routes/web.php b/v4/routes/web.php new file mode 100644 index 0000000000..031438af5c --- /dev/null +++ b/v4/routes/web.php @@ -0,0 +1,57 @@ +group([ + 'prefix' => $apiBase, +], function () use ($router) { + // Forms + $router->group([ + // 'namespace' => 'Forms', + 'prefix' => 'surveys', + 'middleware' => ['scope:forms', 'expiration'] + ], function () use ($router) { + // Public access + $router->get('/', 'SurveyController@index'); + $router->get('/{id}', 'SurveyController@show'); + }); + + $router->group([ + 'prefix' => 'categories', + 'middleware' => ['scope:tags', 'expiration'] + ], function () use ($router) { + // Public access + $router->get('/', 'CategoryController@index'); + $router->get('/{id}', 'CategoryController@show'); + }); + + // Restricted access + $router->group([ + 'prefix' => 'categories', + 'middleware' => ['auth:api', 'scope:tags'] + ], function () use ($router) { + $router->post('/', 'CategoryController@store'); + $router->put('/{id}', 'CategoryController@update'); + $router->delete('/{id}', 'CategoryController@delete'); + }); + + // Restricted access + $router->group([ + 'prefix' => 'surveys', + 'middleware' => ['auth:api', 'scope:forms'] + ], function () use ($router) { + $router->post('/', 'SurveyController@store'); + $router->put('/{id}', 'SurveyController@update'); + $router->delete('/{id}', 'SurveyController@delete'); + }); + + // Restricted access + $router->group([ + 'prefix' => '', + ], function () use ($router) { + $router->get('/languages', 'LanguagesController@index'); + }); +});