\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/controllers/apigeneratorcontroller/reorder.htm b/plugins/ahmadfatoni/apigenerator/controllers/apigeneratorcontroller/reorder.htm
new file mode 100644
index 0000000..9813ab4
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/controllers/apigeneratorcontroller/reorder.htm
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/helpers/Helpers.php b/plugins/ahmadfatoni/apigenerator/helpers/Helpers.php
new file mode 100644
index 0000000..1145f7f
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/helpers/Helpers.php
@@ -0,0 +1,19 @@
+ (isset($statusCode)) ? $statusCode : 500,
+ 'message' => (isset($message)) ? $message : 'error'
+ ];
+ if (count($data) > 0) {
+ $arr['data'] = $data;
+ }
+
+ return response()->json($arr, $arr['status_code']);
+ //return $arr;
+
+ }
+}
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/lang/en/lang.php b/plugins/ahmadfatoni/apigenerator/lang/en/lang.php
new file mode 100644
index 0000000..9ade59e
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/lang/en/lang.php
@@ -0,0 +1,6 @@
+ [
+ 'name' => 'API-Generator',
+ 'description' => 'Generate API base on Builder Plugin'
+ ]
+];
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/models/ApiGenerator.php b/plugins/ahmadfatoni/apigenerator/models/ApiGenerator.php
new file mode 100644
index 0000000..531bfd1
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/models/ApiGenerator.php
@@ -0,0 +1,76 @@
+ 'required|unique:ahmadfatoni_apigenerator_data,name|regex:/^[\pL\s\-]+$/u',
+ 'endpoint' => 'required|unique:ahmadfatoni_apigenerator_data,endpoint',
+ 'custom_format' => 'json'
+ ];
+
+ public $customMessages = [
+ 'custom_format.json' => 'Invalid Json Format Custom Condition'
+ ];
+
+ /*
+ * Disable timestamps by default.
+ * Remove this line if timestamps are defined in the database table.
+ */
+ public $timestamps = false;
+
+ /**
+ * @var string The database table used by the model.
+ */
+ public $table = 'ahmadfatoni_apigenerator_data';
+
+ /**
+ * get model List
+ * @return [type] [description]
+ */
+ public function getModelOptions(){
+
+ return ComponentHelper::instance()->listGlobalModels();
+ }
+
+ /**
+ * [setCustomFormatAttribute description]
+ * @param [type] $value [description]
+ */
+ public function setCustomFormatAttribute($value){
+
+ $json = str_replace('\t', '', $value);
+ $json = json_decode($json);
+
+ if( $json != null){
+
+ if( ! isset($json->fillable) AND ! isset($json->relation) ){
+
+ return $this->attributes['custom_format'] = 'invalid format';
+
+ }
+
+ if( isset($json->relation) AND $json->relation != null ){
+ foreach ($json->relation as $key) {
+ if( !isset($key->name) OR $key->name == null ){
+ return $this->attributes['custom_format'] = 'invalid format';
+ }
+ }
+ }
+ }
+
+ return $this->attributes['custom_format'] = $value;
+
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/models/apigenerator/columns.yaml b/plugins/ahmadfatoni/apigenerator/models/apigenerator/columns.yaml
new file mode 100644
index 0000000..58ce8eb
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/models/apigenerator/columns.yaml
@@ -0,0 +1,9 @@
+columns:
+ name:
+ label: 'API NAME'
+ type: text
+ searchable: true
+ sortable: true
+ endpoint:
+ label: 'BASE ENDPOINT'
+ type: text
diff --git a/plugins/ahmadfatoni/apigenerator/models/apigenerator/fields.yaml b/plugins/ahmadfatoni/apigenerator/models/apigenerator/fields.yaml
new file mode 100644
index 0000000..a8e6e0b
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/models/apigenerator/fields.yaml
@@ -0,0 +1,33 @@
+fields:
+ name:
+ label: 'API Name'
+ oc.commentPosition: ''
+ span: auto
+ placeholder: 'Name of your API'
+ required: 1
+ type: text
+ endpoint:
+ label: 'Base Endpoint'
+ oc.commentPosition: ''
+ span: auto
+ placeholder: api/v1/modulename
+ required: 1
+ type: text
+ description:
+ label: 'Short Description'
+ oc.commentPosition: ''
+ span: auto
+ placeholder: 'Descript your API'
+ type: text
+ model:
+ label: 'Select Model'
+ oc.commentPosition: ''
+ span: auto
+ required: 1
+ type: dropdown
+ custom_format:
+ label: 'Custom Condition (Fillable and Relation)'
+ size: large
+ oc.commentPosition: ''
+ span: full
+ type: textarea
diff --git a/plugins/ahmadfatoni/apigenerator/plugin.yaml b/plugins/ahmadfatoni/apigenerator/plugin.yaml
new file mode 100644
index 0000000..598021d
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/plugin.yaml
@@ -0,0 +1,17 @@
+plugin:
+ name: 'ahmadfatoni.apigenerator::lang.plugin.name'
+ description: 'ahmadfatoni.apigenerator::lang.plugin.description'
+ author: AhmadFatoni
+ icon: oc-icon-bolt
+ homepage: ''
+navigation:
+ api-generator:
+ label: 'API Generator'
+ url: ahmadfatoni/apigenerator/apigeneratorcontroller
+ icon: icon-cogs
+ permissions:
+ - ahmadfatoni.apigenerator.manage
+permissions:
+ ahmadfatoni.apigenerator.manage:
+ tab: 'API Generator'
+ label: 'Manage the API Generator'
diff --git a/plugins/ahmadfatoni/apigenerator/routes.php b/plugins/ahmadfatoni/apigenerator/routes.php
new file mode 100644
index 0000000..fcb83a5
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/routes.php
@@ -0,0 +1,12 @@
+ 'fatoni.generate.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@generateApi'));
+Route::post('fatoni/update/api/{id}', array('as' => 'fatoni.update.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@updateApi'));
+Route::get('fatoni/delete/api/{id}', array('as' => 'fatoni.delete.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@deleteApi'));
+
+Route::resource('api/v1/credit_data', 'AhmadFatoni\ApiGenerator\Controllers\API\creditController', ['except' => ['destroy', 'create', 'edit']]);
+Route::get('api/v1/credit_data/{id}/delete', ['as' => 'api/v1/credit_data.delete', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\API\creditController@destroy']);
+Route::resource('api/v1/card_data', 'AhmadFatoni\ApiGenerator\Controllers\API\cardController', ['except' => ['destroy', 'create', 'edit']]);
+Route::get('api/v1/card_data/{id}/delete', ['as' => 'api/v1/card_data.delete', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\API\cardController@destroy']);
+Route::resource('api/v1/type_account_replenishment', 'AhmadFatoni\ApiGenerator\Controllers\API\typeAccountReplenishmentController', ['except' => ['destroy', 'create', 'edit']]);
+Route::get('api/v1/type_account_replenishment/{id}/delete', ['as' => 'api/v1/type_account_replenishment.delete', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\API\typeAccountReplenishmentController@destroy']);
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/template/controller.dot b/plugins/ahmadfatoni/apigenerator/template/controller.dot
new file mode 100644
index 0000000..ff20ea3
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/template/controller.dot
@@ -0,0 +1,99 @@
+{{modelname}} = ${{modelname}};
+ $this->helpers = $helpers;
+ }
+
+ public function index(){
+
+ $data = $this->{{modelname}}->all()->toArray();
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', $data);
+ }
+
+ public function show($id){
+
+ $data = $this->{{modelname}}::find($id);
+
+ if ($data){
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', [$data]);
+ } else {
+ $this->helpers->apiArrayResponseBuilder(404, 'not found', ['error' => 'Resource id=' . $id . ' could not be found']);
+ }
+
+ }
+
+ public function store(Request $request){
+
+ $arr = $request->all();
+
+ while ( $data = current($arr)) {
+ $this->{{modelname}}->{key($arr)} = $data;
+ next($arr);
+ }
+
+ $validation = Validator::make($request->all(), $this->{{modelname}}->rules);
+
+ if( $validation->passes() ){
+ $this->{{modelname}}->save();
+ return $this->helpers->apiArrayResponseBuilder(201, 'created', ['id' => $this->{{modelname}}->id]);
+ }else{
+ return $this->helpers->apiArrayResponseBuilder(400, 'fail', $validation->errors() );
+ }
+
+ }
+
+ public function update($id, Request $request){
+
+ $status = $this->{{modelname}}->where('id',$id)->update($data);
+
+ if( $status ){
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been updated successfully.');
+
+ }else{
+
+ return $this->helpers->apiArrayResponseBuilder(400, 'bad request', 'Error, data failed to update.');
+
+ }
+ }
+
+ public function delete($id){
+
+ $this->{{modelname}}->where('id',$id)->delete();
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been deleted successfully.');
+ }
+
+ public function destroy($id){
+
+ $this->{{modelname}}->where('id',$id)->delete();
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been deleted successfully.');
+ }
+
+
+ public static function getAfterFilters() {return [];}
+ public static function getBeforeFilters() {return [];}
+ public static function getMiddleware() {return [];}
+ public function callAction($method, $parameters=false) {
+ return call_user_func_array(array($this, $method), $parameters);
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/template/controller.php b/plugins/ahmadfatoni/apigenerator/template/controller.php
new file mode 100644
index 0000000..f78dde0
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/template/controller.php
@@ -0,0 +1,35 @@
+{{modelname}} = ${{modelname}};
+ }
+
+ public static function getAfterFilters() {return [];}
+ public static function getBeforeFilters() {return [];}
+ public static function getMiddleware() {return [];}
+ public function callAction($method, $parameters=false) {
+ return call_user_func_array(array($this, $method), $parameters);
+ }
+
+ // public function create(Request $request){
+
+ // $arr = $request->all();
+
+ // while ( $data = current($arr)) {
+ // $this->
+ // }
+ // return json_encode($this->{{modelname}}->store($request));
+
+ // }
+}
diff --git a/plugins/ahmadfatoni/apigenerator/template/customcontroller.dot b/plugins/ahmadfatoni/apigenerator/template/customcontroller.dot
new file mode 100644
index 0000000..be5cf6f
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/template/customcontroller.dot
@@ -0,0 +1,83 @@
+{{modelname}} = ${{modelname}};
+ $this->helpers = $helpers;
+ }
+
+ {{select}}
+
+ {{show}}
+
+ public function store(Request $request){
+
+ $arr = $request->all();
+
+ while ( $data = current($arr)) {
+ $this->{{modelname}}->{key($arr)} = $data;
+ next($arr);
+ }
+
+ $validation = Validator::make($request->all(), $this->{{modelname}}->rules);
+
+ if( $validation->passes() ){
+ $this->{{modelname}}->save();
+ return $this->helpers->apiArrayResponseBuilder(201, 'created', ['id' => $this->{{modelname}}->id]);
+ }else{
+ return $this->helpers->apiArrayResponseBuilder(400, 'fail', $validation->errors() );
+ }
+
+ }
+
+ public function update($id, Request $request){
+
+ $status = $this->{{modelname}}->where('id',$id)->update($data);
+
+ if( $status ){
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been updated successfully.');
+
+ }else{
+
+ return $this->helpers->apiArrayResponseBuilder(400, 'bad request', 'Error, data failed to update.');
+
+ }
+ }
+
+ public function delete($id){
+
+ $this->{{modelname}}->where('id',$id)->delete();
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been deleted successfully.');
+ }
+
+ public function destroy($id){
+
+ $this->{{modelname}}->where('id',$id)->delete();
+
+ return $this->helpers->apiArrayResponseBuilder(200, 'success', 'Data has been deleted successfully.');
+ }
+
+
+ public static function getAfterFilters() {return [];}
+ public static function getBeforeFilters() {return [];}
+ public static function getMiddleware() {return [];}
+ public function callAction($method, $parameters=false) {
+ return call_user_func_array(array($this, $method), $parameters);
+ }
+
+}
diff --git a/plugins/ahmadfatoni/apigenerator/template/route.dot b/plugins/ahmadfatoni/apigenerator/template/route.dot
new file mode 100644
index 0000000..515cb53
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/template/route.dot
@@ -0,0 +1,3 @@
+
+Route::resource('{{modelname}}', 'AhmadFatoni\ApiGenerator\Controllers\API\{{controllername}}Controller', ['except' => ['destroy', 'create', 'edit']]);
+Route::get('{{modelname}}/{id}/delete', ['as' => '{{modelname}}.delete', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\API\{{controllername}}Controller@destroy']);
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/template/routes.dot b/plugins/ahmadfatoni/apigenerator/template/routes.dot
new file mode 100644
index 0000000..ddafe59
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/template/routes.dot
@@ -0,0 +1,6 @@
+ 'fatoni.generate.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@generateApi'));
+Route::post('fatoni/update/api/{id}', array('as' => 'fatoni.update.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@updateApi'));
+Route::get('fatoni/delete/api/{id}', array('as' => 'fatoni.delete.api', 'uses' => 'AhmadFatoni\ApiGenerator\Controllers\ApiGeneratorController@deleteApi'));
+{{route}}
\ No newline at end of file
diff --git a/plugins/ahmadfatoni/apigenerator/updates/builder_table_create_ahmadfatoni_apigenerator_data.php b/plugins/ahmadfatoni/apigenerator/updates/builder_table_create_ahmadfatoni_apigenerator_data.php
new file mode 100644
index 0000000..44cdc81
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/updates/builder_table_create_ahmadfatoni_apigenerator_data.php
@@ -0,0 +1,26 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('name');
+ $table->string('endpoint');
+ $table->string('model');
+ $table->string('description')->nullable();
+ $table->text('custom_format')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('ahmadfatoni_apigenerator_data');
+ }
+}
diff --git a/plugins/ahmadfatoni/apigenerator/updates/version.yaml b/plugins/ahmadfatoni/apigenerator/updates/version.yaml
new file mode 100644
index 0000000..966ff30
--- /dev/null
+++ b/plugins/ahmadfatoni/apigenerator/updates/version.yaml
@@ -0,0 +1,15 @@
+1.0.1:
+ - 'Initialize plugin.'
+1.0.2:
+ - 'Database implementation'
+1.0.3:
+ - 'add builder plugin on requirements dependency'
+ - builder_table_create_ahmadfatoni_apigenerator_data.php
+1.0.4:
+ - 'fixing bug on PHP 7'
+1.0.5:
+ - 'fixing bug on request delete data'
+1.0.6:
+ - 'fixing bug on generate endpoint'
+1.0.7:
+ - 'fixing bug on October CMS v1.0.456'
\ No newline at end of file
diff --git a/plugins/offline/cors/.gitignore b/plugins/offline/cors/.gitignore
new file mode 100644
index 0000000..57872d0
--- /dev/null
+++ b/plugins/offline/cors/.gitignore
@@ -0,0 +1 @@
+/vendor/
diff --git a/plugins/offline/cors/CONTRIBUTING.md b/plugins/offline/cors/CONTRIBUTING.md
new file mode 100644
index 0000000..f74dc2f
--- /dev/null
+++ b/plugins/offline/cors/CONTRIBUTING.md
@@ -0,0 +1,7 @@
+# How to contribute
+
+Contributions to this project are highly welcome.
+
+1. Submit your pull requests to the `develop` branch
+1. Adhere to the [PSR-2 coding](http://www.php-fig.org/psr/psr-2/) standard
+1. If you are not sure if your ideas are fit for this project, create an issue and ask
\ No newline at end of file
diff --git a/plugins/offline/cors/LICENSE b/plugins/offline/cors/LICENSE
new file mode 100644
index 0000000..6aece30
--- /dev/null
+++ b/plugins/offline/cors/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 OFFLINE GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/offline/cors/Plugin.php b/plugins/offline/cors/Plugin.php
new file mode 100644
index 0000000..0dcb0e2
--- /dev/null
+++ b/plugins/offline/cors/Plugin.php
@@ -0,0 +1,49 @@
+app['Illuminate\Contracts\Http\Kernel']
+ ->prependMiddleware(HandleCors::class);
+
+ if (request()->isMethod('OPTIONS')) {
+ $this->app['Illuminate\Contracts\Http\Kernel']
+ ->prependMiddleware(HandlePreflight::class);
+ }
+ }
+
+ public function registerPermissions()
+ {
+ return [
+ 'offline.cors.manage' => [
+ 'label' => 'Can manage cors settings',
+ 'tab' => 'CORS',
+ ],
+ ];
+ }
+
+ public function registerSettings()
+ {
+ return [
+ 'cors' => [
+ 'label' => 'CORS-Settings',
+ 'description' => 'Manage CORS headers',
+ 'category' => 'system::lang.system.categories.cms',
+ 'icon' => 'icon-code',
+ 'class' => Settings::class,
+ 'order' => 500,
+ 'keywords' => 'cors',
+ 'permissions' => ['offline.cors.manage'],
+ ],
+ ];
+ }
+}
diff --git a/plugins/offline/cors/README.md b/plugins/offline/cors/README.md
new file mode 100644
index 0000000..ecbb035
--- /dev/null
+++ b/plugins/offline/cors/README.md
@@ -0,0 +1,45 @@
+# CORS plugin for October CMS
+
+This plugin is based on [https://github.com/barryvdh/laravel-cors](https://github.com/barryvdh/laravel-cors/blob/master/config/cors.php).
+
+All configuration for the plugin can be done via the backend settings.
+
+The following cors headers are supported:
+
+* Access-Control-Allow-Origin
+* Access-Control-Allow-Headers
+* Access-Control-Allow-Methods
+* Access-Control-Allow-Credentials
+* Access-Control-Expose-Headers
+* Access-Control-Max-Age
+
+Currently these headers are sent for every request. There is no per-route configuration possible at this time.
+
+## Setup
+
+After installing the plugin visit the CORS settings page in your October CMS backend settings.
+
+You can add `*` as an entry to `Allowed origins`, `Allowed headers` and `Allowed methods` to allow any kind of CORS request from everywhere.
+
+It is advised to be more explicit about these settings. You can add values for each header via the repeater fields.
+
+> It is important to set these intial settings once for the plugin to work as excpected!
+
+### Filesystem configuration
+
+As an alternative to the backend settings you can create a `config/config.php` file in the plugins root directory to configure it.
+
+The filesystem configuration will overwrite any defined backend setting.
+
+```php
+ true,
+ 'maxAge' => 3600,
+ 'allowedOrigins' => ['*'],
+ 'allowedHeaders' => ['*'],
+ 'allowedMethods' => ['GET', 'POST'],
+ 'exposedHeaders' => [''],
+];
+```
\ No newline at end of file
diff --git a/plugins/offline/cors/classes/Cors.php b/plugins/offline/cors/classes/Cors.php
new file mode 100644
index 0000000..2ceeade
--- /dev/null
+++ b/plugins/offline/cors/classes/Cors.php
@@ -0,0 +1,71 @@
+ [],
+ 'allowedMethods' => [],
+ 'allowedOrigins' => [],
+ 'exposedHeaders' => false,
+ 'maxAge' => false,
+ 'supportsCredentials' => false,
+ ];
+
+ /**
+ * Cors constructor.
+ *
+ * @param HttpKernelInterface $app
+ * @param array $options
+ */
+ public function __construct(HttpKernelInterface $app, array $options = [])
+ {
+ $this->app = $app;
+ $this->cors = new CorsService(array_merge($this->defaultOptions, $options));
+
+ }
+
+ /**
+ * @param Request $request
+ * @param int $type
+ * @param bool $catch
+ *
+ * @return bool|Response
+ */
+ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
+ {
+ if ( ! $this->cors->isCorsRequest($request)) {
+ return $this->app->handle($request, $type, $catch);
+ }
+
+ if ($this->cors->isPreflightRequest($request)) {
+ return $this->cors->handlePreflightRequest($request);
+ }
+
+ if ( ! $this->cors->isActualRequestAllowed($request)) {
+ return new Response('Not allowed.', 403);
+ }
+
+ $response = $this->app->handle($request, $type, $catch);
+
+ return $this->cors->addActualRequestHeaders($response, $request);
+ }
+}
\ No newline at end of file
diff --git a/plugins/offline/cors/classes/CorsService.php b/plugins/offline/cors/classes/CorsService.php
new file mode 100644
index 0000000..41758e7
--- /dev/null
+++ b/plugins/offline/cors/classes/CorsService.php
@@ -0,0 +1,199 @@
+options = $this->normalizeOptions($options);
+ }
+
+ private function normalizeOptions(array $options = [])
+ {
+ $options += [
+ 'supportsCredentials' => false,
+ 'maxAge' => 0,
+ ];
+
+ // Make sure these values are arrays, if not specified in the backend settings.
+ $arrayKeys = [
+ 'allowedOrigins',
+ 'allowedHeaders',
+ 'exposedHeaders',
+ 'allowedMethods',
+ ];
+
+ foreach ($arrayKeys as $key) {
+ if (!$options[$key]) {
+ $options[$key] = [];
+ }
+ }
+
+ // normalize array('*') to true
+ if (in_array('*', $options['allowedOrigins'])) {
+ $options['allowedOrigins'] = true;
+ }
+ if (in_array('*', $options['allowedHeaders'])) {
+ $options['allowedHeaders'] = true;
+ } else {
+ $options['allowedHeaders'] = array_map('strtolower', $options['allowedHeaders']);
+ }
+
+ if (in_array('*', $options['allowedMethods'])) {
+ $options['allowedMethods'] = true;
+ } else {
+ $options['allowedMethods'] = array_map('strtoupper', $options['allowedMethods']);
+ }
+
+ return $options;
+ }
+
+ public function isActualRequestAllowed(Request $request)
+ {
+ return $this->checkOrigin($request);
+ }
+
+ public function isCorsRequest(Request $request)
+ {
+ return $request->headers->has('Origin') && $request->headers->get('Origin') !== $request->getSchemeAndHttpHost();
+ }
+
+ public function isPreflightRequest(Request $request)
+ {
+ return $this->isCorsRequest($request)
+ && $request->getMethod() === 'OPTIONS'
+ && $request->headers->has('Access-Control-Request-Method');
+ }
+
+ public function addActualRequestHeaders(Response $response, Request $request)
+ {
+ if ( ! $this->checkOrigin($request)) {
+ return $response;
+ }
+
+ $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
+
+ if ( ! $response->headers->has('Vary')) {
+ $response->headers->set('Vary', 'Origin');
+ } else {
+ $response->headers->set('Vary', $response->headers->get('Vary') . ', Origin');
+ }
+
+ if ($this->options['supportsCredentials']) {
+ $response->headers->set('Access-Control-Allow-Credentials', 'true');
+ }
+
+ if ($this->options['exposedHeaders']) {
+ $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->options['exposedHeaders']));
+ }
+
+ return $response;
+ }
+
+ public function handlePreflightRequest(Request $request)
+ {
+ if (true !== $check = $this->checkPreflightRequestConditions($request)) {
+ return $check;
+ }
+
+ return $this->buildPreflightCheckResponse($request);
+ }
+
+ private function buildPreflightCheckResponse(Request $request)
+ {
+ $response = new Response();
+
+ if ($this->options['supportsCredentials']) {
+ $response->headers->set('Access-Control-Allow-Credentials', 'true');
+ }
+
+ $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
+
+ if ($this->options['maxAge']) {
+ $response->headers->set('Access-Control-Max-Age', $this->options['maxAge']);
+ }
+
+ $allowMethods = $this->options['allowedMethods'] === true
+ ? strtoupper($request->headers->get('Access-Control-Request-Method'))
+ : implode(', ', $this->options['allowedMethods']);
+ $response->headers->set('Access-Control-Allow-Methods', $allowMethods);
+
+ $allowHeaders = $this->options['allowedHeaders'] === true
+ ? strtoupper($request->headers->get('Access-Control-Request-Headers'))
+ : implode(', ', $this->options['allowedHeaders']);
+ $response->headers->set('Access-Control-Allow-Headers', $allowHeaders);
+
+ return $response;
+ }
+
+ private function checkPreflightRequestConditions(Request $request)
+ {
+ if ( ! $this->checkOrigin($request)) {
+ return $this->createBadRequestResponse(403, 'Origin not allowed');
+ }
+
+ if ( ! $this->checkMethod($request)) {
+ return $this->createBadRequestResponse(405, 'Method not allowed');
+ }
+
+ $requestHeaders = [];
+ // if allowedHeaders has been set to true ('*' allow all flag) just skip this check
+ if ($this->options['allowedHeaders'] !== true && $request->headers->has('Access-Control-Request-Headers')) {
+ $headers = strtolower($request->headers->get('Access-Control-Request-Headers'));
+ $requestHeaders = explode(',', $headers);
+
+ foreach ($requestHeaders as $header) {
+ if ( ! in_array(trim($header), $this->options['allowedHeaders'])) {
+ return $this->createBadRequestResponse(403, 'Header not allowed');
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private function createBadRequestResponse($code, $reason = '')
+ {
+ return new Response($reason, $code);
+ }
+
+ private function checkOrigin(Request $request)
+ {
+ if ($this->options['allowedOrigins'] === true) {
+ // allow all '*' flag
+ return true;
+ }
+ $origin = $request->headers->get('Origin');
+
+ foreach ($this->options['allowedOrigins'] as $allowedOrigin) {
+ if (OriginMatcher::matches($allowedOrigin, $origin)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function checkMethod(Request $request)
+ {
+ if ($this->options['allowedMethods'] === true) {
+ // allow all '*' flag
+ return true;
+ }
+
+ $requestMethod = strtoupper($request->headers->get('Access-Control-Request-Method'));
+
+ return in_array($requestMethod, $this->options['allowedMethods']);
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/offline/cors/classes/HandleCors.php b/plugins/offline/cors/classes/HandleCors.php
new file mode 100644
index 0000000..9f2a88d
--- /dev/null
+++ b/plugins/offline/cors/classes/HandleCors.php
@@ -0,0 +1,49 @@
+cors = $cors;
+ }
+
+ /**
+ * Handle an incoming request. Based on Asm89\Stack\Cors by asm89
+ * @see https://github.com/asm89/stack-cors/blob/master/src/Asm89/Stack/Cors.php
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ *
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ if ( ! $this->cors->isCorsRequest($request)) {
+ return $next($request);
+ }
+
+ if ( ! $this->cors->isActualRequestAllowed($request)) {
+ abort(403);
+ }
+
+ /** @var \Illuminate\Http\Response $response */
+ $response = $next($request);
+
+ return $this->cors->addActualRequestHeaders($response, $request);
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/offline/cors/classes/HandlePreflight.php b/plugins/offline/cors/classes/HandlePreflight.php
new file mode 100644
index 0000000..392b5a3
--- /dev/null
+++ b/plugins/offline/cors/classes/HandlePreflight.php
@@ -0,0 +1,74 @@
+cors = $cors;
+ $this->router = $router;
+ $this->kernel = $kernel;
+ }
+
+ /**
+ * Handle an incoming OPTIONS request.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ *
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ $response = $next($request);
+ if ($this->cors->isPreflightRequest($request) && $this->hasMatchingCorsRoute($request)) {
+ $preflight = $this->cors->handlePreflightRequest($request);
+ $response->headers->add($preflight->headers->all());
+ }
+ $response->setStatusCode(204);
+ return $response;
+ }
+
+ /**
+ * Verify the current OPTIONS request matches a CORS-enabled route
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return boolean
+ */
+ private function hasMatchingCorsRoute($request)
+ {
+ // Check if CORS is added in a global middleware
+ if ($this->kernel->hasMiddleware(HandleCors::class)) {
+ return true;
+ }
+
+ // Check if CORS is added as a route middleware
+ $request = clone $request;
+ $request->setMethod($request->header('Access-Control-Request-Method'));
+
+ try {
+ $route = $this->router->getRoutes()->match($request);
+ // change of method name in laravel 5.3
+ if (method_exists($this->router, 'gatherRouteMiddleware')) {
+ $middleware = $this->router->gatherRouteMiddleware($route);
+ } else {
+ $middleware = $this->router->gatherRouteMiddlewares($route);
+ }
+
+ return in_array(HandleCors::class, $middleware);
+ } catch (\Exception $e) {
+ app('log')->error($e);
+
+ return false;
+ }
+ }
+}
diff --git a/plugins/offline/cors/classes/OriginMatcher.php b/plugins/offline/cors/classes/OriginMatcher.php
new file mode 100644
index 0000000..bf529d2
--- /dev/null
+++ b/plugins/offline/cors/classes/OriginMatcher.php
@@ -0,0 +1,100 @@
+ $patternComponent) {
+ if ($patternComponent === '*') {
+ return true;
+ }
+ if ( ! isset($hostComponents[$index])) {
+ return false;
+ }
+ if ($hostComponents[$index] !== $patternComponent) {
+ return false;
+ }
+ }
+
+ return count($patternComponents) === count($hostComponents);
+ }
+
+ public static function portMatches($pattern, $port)
+ {
+ if ($pattern === "*") {
+ return true;
+ }
+ if ((string)$pattern === "") {
+ return (string)$port === "";
+ }
+ if (preg_match('/\A\d+\z/', $pattern)) {
+ return (string)$pattern === (string)$port;
+ }
+ if (preg_match('/\A(?P\d+)-(?P\d+)\z/', $pattern, $captured)) {
+ return $captured['from'] <= $port && $port <= $captured['to'];
+ }
+ throw new \InvalidArgumentException("Invalid port pattern: ${pattern}");
+ }
+
+ public static function parseOriginPattern($originPattern, $component = -1)
+ {
+ $matched = preg_match(
+ '!\A
+ (?: (?P ([a-z][a-z0-9+\-.]*) ):// )?
+ (?P (?:\*|[\w-]+)(?:\.[\w-]+)* )
+ (?: :(?P (?: \*|\d+(?:-\d+)? ) ) )?
+ \z!x',
+ $originPattern,
+ $captured
+ );
+ if ( ! $matched) {
+ throw new \InvalidArgumentException("Invalid origin pattern ${originPattern}");
+ }
+ $components = [
+ 'scheme' => $captured['scheme'] ?: null,
+ 'host' => $captured['host'],
+ 'port' => array_key_exists('port', $captured) ? $captured['port'] : null,
+ ];
+ switch ($component) {
+ case -1:
+ return $components;
+ case PHP_URL_SCHEME:
+ return $components['scheme'];
+ case PHP_URL_HOST:
+ return $components['host'];
+ case PHP_URL_PORT:
+ return $components['port'];
+ }
+ throw new \InvalidArgumentException("Invalid component: ${component}");
+ }
+}
\ No newline at end of file
diff --git a/plugins/offline/cors/classes/ServiceProvider.php b/plugins/offline/cors/classes/ServiceProvider.php
new file mode 100644
index 0000000..7138f9c
--- /dev/null
+++ b/plugins/offline/cors/classes/ServiceProvider.php
@@ -0,0 +1,90 @@
+app->singleton(CorsService::class, function ($app) {
+ return new CorsService($this->getSettings());
+ });
+ }
+
+ /**
+ * Return default Settings
+ */
+ protected function getSettings()
+ {
+ $supportsCredentials = (bool)$this->getConfigValue('supportsCredentials', false);
+ $maxAge = (int)$this->getConfigValue('maxAge', 0);
+ $allowedOrigins = $this->getConfigValue('allowedOrigins', []);
+ $allowedHeaders = $this->getConfigValue('allowedHeaders', []);
+ $allowedMethods = $this->getConfigValue('allowedMethods', []);
+ $exposedHeaders = $this->getConfigValue('exposedHeaders', []);
+
+ return compact(
+ 'supportsCredentials',
+ 'allowedOrigins',
+ 'allowedHeaders',
+ 'allowedMethods',
+ 'exposedHeaders',
+ 'maxAge'
+ );
+ }
+
+ /**
+ * Returns an effective config value.
+ *
+ * If a filesystem config is available it takes precedence
+ * over the backend settings values.
+ *
+ * @param $key
+ * @param null $default
+ *
+ * @return mixed
+ */
+ public function getConfigValue($key, $default = null)
+ {
+ return $this->filesystemConfig($key) ?: $this->getValues(Settings::get($key, $default));
+ }
+
+ /**
+ * Return the filesystem config value if available.
+ *
+ * @param string $key
+ *
+ * @return mixed
+ */
+ public function filesystemConfig($key)
+ {
+ return config('offline.cors::' . $key);
+ }
+
+ /**
+ * Extract the repeater field values.
+ *
+ * @param mixed $values
+ *
+ * @return array
+ */
+ protected function getValues($values)
+ {
+ return \is_array($values) ? collect($values)->pluck('value')->toArray() : $values;
+ }
+}
\ No newline at end of file
diff --git a/plugins/offline/cors/composer.json b/plugins/offline/cors/composer.json
new file mode 100644
index 0000000..4ce370d
--- /dev/null
+++ b/plugins/offline/cors/composer.json
@@ -0,0 +1,13 @@
+{
+ "name": "offline/oc-cors-plugin",
+ "description": "Setup and manage Cross-Origin Resource Sharing headers in October CMS",
+ "type": "october-plugin",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Tobias Kündig",
+ "email": "tobias@offline.swiss"
+ }
+ ],
+ "require": {}
+}
diff --git a/plugins/offline/cors/lang/de/lang.php b/plugins/offline/cors/lang/de/lang.php
new file mode 100644
index 0000000..0df3bb1
--- /dev/null
+++ b/plugins/offline/cors/lang/de/lang.php
@@ -0,0 +1,6 @@
+ [
+ 'name' => 'CORS',
+ 'description' => 'Verwalte Cross-Origin Resource Sharing Header in October CMS',
+ ],
+];
\ No newline at end of file
diff --git a/plugins/offline/cors/lang/en/lang.php b/plugins/offline/cors/lang/en/lang.php
new file mode 100644
index 0000000..beaa2e5
--- /dev/null
+++ b/plugins/offline/cors/lang/en/lang.php
@@ -0,0 +1,6 @@
+ [
+ 'name' => 'CORS',
+ 'description' => 'Setup and manage Cross-Origin Resource Sharing headers',
+ ],
+];
\ No newline at end of file
diff --git a/plugins/offline/cors/models/Settings.php b/plugins/offline/cors/models/Settings.php
new file mode 100644
index 0000000..381d13f
--- /dev/null
+++ b/plugins/offline/cors/models/Settings.php
@@ -0,0 +1,12 @@
+ 'rainlab.blog::lang.plugin.name',
+ 'description' => 'rainlab.blog::lang.plugin.description',
+ 'author' => 'Alexey Bobkov, Samuel Georges',
+ 'icon' => 'icon-pencil',
+ 'homepage' => 'https://github.com/rainlab/blog-plugin'
+ ];
+ }
+
+ public function registerComponents()
+ {
+ return [
+ 'RainLab\Blog\Components\Post' => 'blogPost',
+ 'RainLab\Blog\Components\Posts' => 'blogPosts',
+ 'RainLab\Blog\Components\Categories' => 'blogCategories',
+ 'RainLab\Blog\Components\RssFeed' => 'blogRssFeed'
+ ];
+ }
+
+ public function registerPermissions()
+ {
+ return [
+ 'rainlab.blog.manage_settings' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.manage_settings'
+ ],
+ 'rainlab.blog.access_posts' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.access_posts'
+ ],
+ 'rainlab.blog.access_categories' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.access_categories'
+ ],
+ 'rainlab.blog.access_other_posts' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.access_other_posts'
+ ],
+ 'rainlab.blog.access_import_export' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.access_import_export'
+ ],
+ 'rainlab.blog.access_publish' => [
+ 'tab' => 'rainlab.blog::lang.blog.tab',
+ 'label' => 'rainlab.blog::lang.blog.access_publish'
+ ]
+ ];
+ }
+
+ public function registerNavigation()
+ {
+ return [
+ 'blog' => [
+ 'label' => 'rainlab.blog::lang.blog.menu_label',
+ 'url' => Backend::url('rainlab/blog/posts'),
+ 'icon' => 'icon-pencil',
+ 'iconSvg' => 'plugins/rainlab/blog/assets/images/blog-icon.svg',
+ 'permissions' => ['rainlab.blog.*'],
+ 'order' => 300,
+
+ 'sideMenu' => [
+ 'new_post' => [
+ 'label' => 'rainlab.blog::lang.posts.new_post',
+ 'icon' => 'icon-plus',
+ 'url' => Backend::url('rainlab/blog/posts/create'),
+ 'permissions' => ['rainlab.blog.access_posts']
+ ],
+ 'posts' => [
+ 'label' => 'rainlab.blog::lang.blog.posts',
+ 'icon' => 'icon-copy',
+ 'url' => Backend::url('rainlab/blog/posts'),
+ 'permissions' => ['rainlab.blog.access_posts']
+ ],
+ 'categories' => [
+ 'label' => 'rainlab.blog::lang.blog.categories',
+ 'icon' => 'icon-list-ul',
+ 'url' => Backend::url('rainlab/blog/categories'),
+ 'permissions' => ['rainlab.blog.access_categories']
+ ]
+ ]
+ ]
+ ];
+ }
+
+ public function registerSettings()
+ {
+ return [
+ 'blog' => [
+ 'label' => 'rainlab.blog::lang.blog.menu_label',
+ 'description' => 'rainlab.blog::lang.blog.settings_description',
+ 'category' => 'rainlab.blog::lang.blog.menu_label',
+ 'icon' => 'icon-pencil',
+ 'class' => 'RainLab\Blog\Models\Settings',
+ 'order' => 500,
+ 'keywords' => 'blog post category',
+ 'permissions' => ['rainlab.blog.manage_settings']
+ ]
+ ];
+ }
+
+ /**
+ * Register method, called when the plugin is first registered.
+ */
+ public function register()
+ {
+ /*
+ * Register the image tag processing callback
+ */
+ TagProcessor::instance()->registerCallback(function($input, $preview) {
+ if (!$preview) {
+ return $input;
+ }
+
+ return preg_replace('|\]*)\/>|m',
+ '
+
+ Click or drop an image...
+
+
+ ',
+ $input);
+ });
+ }
+
+ public function boot()
+ {
+ /*
+ * Register menu items for the RainLab.Pages plugin
+ */
+ Event::listen('pages.menuitem.listTypes', function() {
+ return [
+ 'blog-category' => 'rainlab.blog::lang.menuitem.blog_category',
+ 'all-blog-categories' => 'rainlab.blog::lang.menuitem.all_blog_categories',
+ 'blog-post' => 'rainlab.blog::lang.menuitem.blog_post',
+ 'all-blog-posts' => 'rainlab.blog::lang.menuitem.all_blog_posts',
+ 'category-blog-posts' => 'rainlab.blog::lang.menuitem.category_blog_posts',
+ ];
+ });
+
+ Event::listen('pages.menuitem.getTypeInfo', function($type) {
+ if ($type == 'blog-category' || $type == 'all-blog-categories') {
+ return Category::getMenuTypeInfo($type);
+ }
+ elseif ($type == 'blog-post' || $type == 'all-blog-posts' || $type == 'category-blog-posts') {
+ return Post::getMenuTypeInfo($type);
+ }
+ });
+
+ Event::listen('pages.menuitem.resolveItem', function($type, $item, $url, $theme) {
+ if ($type == 'blog-category' || $type == 'all-blog-categories') {
+ return Category::resolveMenuItem($item, $url, $theme);
+ }
+ elseif ($type == 'blog-post' || $type == 'all-blog-posts' || $type == 'category-blog-posts') {
+ return Post::resolveMenuItem($item, $url, $theme);
+ }
+ });
+ }
+}
diff --git a/plugins/rainlab/blog/README.md b/plugins/rainlab/blog/README.md
new file mode 100644
index 0000000..f96addd
--- /dev/null
+++ b/plugins/rainlab/blog/README.md
@@ -0,0 +1,318 @@
+# Blog Plugin
+
+A simple, extensible blogging platform for October CMS.
+
+[Blog & Forum Building Tutorial Video](https://player.vimeo.com/video/97088926)
+
+## Editing posts
+
+The plugin uses the markdown markup for the posts. You can use any Markdown syntax and some special tags for embedding images and videos (requires RainLab Blog Video plugin). To embed an image use the image placeholder:
+
+ 
+
+The number in the first part is the placeholder index. If you use multiple images in a post you should use an unique index for each image:
+
+ 
+
+ 
+
+You can also add classes or ids to images by using the [markdown extra](http://michelf.ca/projects/php-markdown/extra/) syntax:
+
+ {#id .class}
+
+## Excerpt Vs. Read more
+
+Posts are managed by selecting *Blog > Posts* from the menu. Each post can contain an excerpt by entering some text in this field on the *Manage* tab. This content is displayed on the page using the `summary` attribute of the blog post.
+
+ {{ post.summary|raw }}
+
+Alternatively this field can be left blank and the excerpt can be captured from the main content (*Edit* tab). Use the special tag `` to specify a summary from the main content, all content above this tag will be treated as the summary. For example:
+
+ This is a great introduction to a great blog post. This text is included as part of the excerpt / summary.
+
+
+
+ Let's dive in to more detail about why this post is so great. This text will not be included in the summary.
+
+Finally, if no excerpt is specified and the "more" tag is not used, the blog post will capture the first 600 characters of the content and use this for the summary.
+
+## Implementing front-end pages
+
+The plugin provides several components for building the post list page (archive), category page, post details page and category list for the sidebar.
+
+### Post list page
+
+Use the `blogPosts` component to display a list of latest blog posts on a page. The component has the following properties:
+
+* **pageNumber** - this value is used to determine what page the user is on, it should be a routing parameter for the default markup. The default value is **{{ :page }}** to obtain the value from the route parameter `:page`.
+* **categoryFilter** - a category slug to filter the posts by. If left blank, all posts are displayed.
+* **postsPerPage** - how many posts to display on a single page (the pagination is supported automatically). The default value is 10.
+* **noPostsMessage** - message to display in the empty post list.
+* **sortOrder** - the column name and direction used for the sort order of the posts. The default value is **published_at desc**.
+* **categoryPage** - path to the category page. The default value is **blog/category** - it matches the pages/blog/category.htm file in the theme directory. This property is used in the default component partial for creating links to the blog categories.
+* **postPage** - path to the post details page. The default value is **blog/post** - it matches the pages/blog/post.htm file in the theme directory. This property is used in the default component partial for creating links to the blog posts.
+* **exceptPost** - ignore a single post by its slug or unique ID. The ignored post will not be included in the list, useful for showing other/related posts.
+* **exceptCategories** - ignore posts from a comma-separated list of categories, given by their unique slug. The ignored posts will not be included in the list.
+
+The blogPosts component injects the following variables to the page where it's used:
+
+* **posts** - a list of blog posts loaded from the database.
+* **postPage** - contains the value of the `postPage` component's property.
+* **category** - the blog category object loaded from the database. If the category is not found, the variable value is **null**.
+* **categoryPage** - contains the value of the `categoryPage` component's property.
+* **noPostsMessage** - contains the value of the `noPostsMessage` component's property.
+
+The component supports pagination and reads the current page index from the `:page` URL parameter. The next example shows the basic component usage on the blog home page:
+
+ title = "Blog"
+ url = "/blog/:page?"
+
+ [blogPosts]
+ postsPerPage = "5"
+ ==
+ {% component 'blogPosts' %}
+
+The next example shows the basic component usage with the category filter:
+
+ title = "Blog Category"
+ url = "/blog/category/:slug/:page?"
+
+ [blogPosts]
+ categoryFilter = "{{ :slug }}"
+ ==
+ function onEnd()
+ {
+ // Optional - set the page title to the category name
+ if ($this->category)
+ $this->page->title = $this->category->name;
+ }
+ ==
+ {% if not category %}
+
Category not found
+ {% else %}
+
{{ category.name }}
+
+ {% component 'blogPosts' %}
+ {% endif %}
+
+The post list and the pagination are coded in the default component partial `plugins/rainlab/blog/components/posts/default.htm`. If the default markup is not suitable for your website, feel free to copy it from the default partial and replace the `{% component %}` call in the example above with the partial contents.
+
+### Post page
+
+Use the `blogPost` component to display a blog post on a page. The component has the following properties:
+
+* **slug** - the value used for looking up the post by its slug. The default value is **{{ :slug }}** to obtain the value from the route parameter `:slug`.
+* **categoryPage** - path to the category page. The default value is **blog/category** - it matches the pages/blog/category.htm file in the theme directory. This property is used in the default component partial for creating links to the blog categories.
+
+The component injects the following variables to the page where it's used:
+
+* **post** - the blog post object loaded from the database. If the post is not found, the variable value is **null**.
+
+The next example shows the basic component usage on the blog page:
+
+ title = "Blog Post"
+ url = "/blog/post/:slug"
+
+ [blogPost]
+ ==
+ post)
+ $this->page->title = $this->post->title;
+ }
+ ?>
+ ==
+ {% if post %}
+
{{ post.title }}
+
+ {% component 'blogPost' %}
+ {% else %}
+
Post not found
+ {% endif %}
+
+The post details is coded in the default component partial `plugins/rainlab/blog/components/post/default.htm`.
+
+### Category list
+
+Use the `blogCategories` component to display a list of blog post categories with links. The component has the following properties:
+
+* **slug** - the value used for looking up the current category by its slug. The default value is **{{ :slug }}** to obtain the value from the route parameter `:slug`.
+* **displayEmpty** - determines if empty categories should be displayed. The default value is false.
+* **categoryPage** - path to the category page. The default value is **blog/category** - it matches the pages/blog/category.htm file in the theme directory. This property is used in the default component partial for creating links to the blog categories.
+
+The component injects the following variables to the page where it's used:
+
+* **categoryPage** - contains the value of the `categoryPage` component's property.
+* **categories** - a list of blog categories loaded from the database.
+* **currentCategorySlug** - slug of the current category. This property is used for marking the current category in the category list.
+
+The component can be used on any page. The next example shows the basic component usage on the blog home page:
+
+ title = "Blog"
+ url = "/blog/:page?"
+
+ [blogCategories]
+ ==
+ ...
+
+ {% component 'blogCategories' %}
+
+ ...
+
+The category list is coded in the default component partial `plugins/rainlab/blog/components/categories/default.htm`.
+
+### RSS feed
+
+Use the `blogRssFeed` component to display an RSS feed containing the latest blog posts. The following properties are supported:
+
+* **categoryFilter** - a category slug to filter the posts by. If left blank, all posts are displayed.
+* **postsPerPage** - how many posts to display on the feed. The default value is 10.
+* **blogPage** - path to the main blog page. The default value is **blog** - it matches the pages/blog.htm file in the theme directory. This property is used in the RSS feed for creating links to the main blog page.
+* **postPage** - path to the post details page. The default value is **blog/post** - it matches the pages/blog/post.htm file in the theme directory. This property is used in the RSS feed for creating links to the blog posts.
+
+The component can be used on any page, it will hijack the entire page cycle to display the feed in RSS format. The next example shows how to use it:
+
+ title = "RSS Feed"
+ url = "/blog/rss.xml"
+
+ [blogRssFeed]
+ blogPage = "blog"
+ postPage = "blog/post"
+ ==
+
+
+## Configuration
+
+To overwrite the default configuration create a `config/rainlab/blog/config.php`. You can return only values you want to override.
+
+### Summary
+
+A summary attribute is generated for each post.
+
+If you enter an excerpt manually, it gets used as summary. Alternatively, you can use the `summary_separator` (default is ``) to mark the end of the summary. If a post contains no separator, the text gets truncated after the number of characters specified in `summary_default_length` (default is 600 characters).
+
+## Markdown guide
+
+October supports [standard markdown syntax](http://daringfireball.net/projects/markdown/) as well as [extended markdown syntax](http://michelf.ca/projects/php-markdown/extra/)
+
+### Classes and IDs
+
+Classes and IDs can be added to images and other elements as shown below:
+
+```
+[link](url){#id .class}
+{#id .class}
+# October {#id .class}
+```
+
+### Fenced code blogs
+
+Markdown extra makes it possible to use fenced code blocks. With fenced code blocks you do not need indentation on the areas you want to mark as code:
+
+
+ ```
+ Code goes here
+ ```
+
+You can also use the `~` symbol:
+
+ ~~~
+ Code goes here
+ ~~~
+
+### Tables
+
+A *simple* table can be defined as follows:
+
+```
+First Header | Second Header
+------------- | -------------
+Content Cell | Content Cell
+Content Cell | Content Cell
+```
+
+If you want to you can also add a leading and tailing pipe:
+
+```
+| First Header | Second Header |
+| ------------- | ------------- |
+| Content Cell | Content Cell |
+| Content Cell | Content Cell |
+```
+
+To add alignment to the cells you simply need to add a `:` either at the start or end of a separator:
+
+```
+| First Header | Second Header |
+| :------------ | ------------: |
+| Content Cell | Content Cell |
+| Content Cell | Content Cell |
+```
+
+To center align cell just add `:` on both sides:
+
+```
+| First Header | Second Header |
+| ------------- | :-----------: |
+| Content Cell | Content Cell |
+| Content Cell | Content Cell |
+```
+
+### Definition lists
+
+Below is an example of a simple definition list:
+
+```
+Laravel
+: A popular PHP framework
+
+October
+: Awesome CMS built on Laravel
+```
+
+A term can also have multiple definitions:
+
+```
+Laravel
+: A popular PHP framework
+
+October
+: Awesome CMS built on Laravel
+: Supports markdown extra
+```
+
+You can also associate more than 1 term to a definition:
+
+```
+Laravel
+October
+: Built using PHP
+```
+
+### Footnotes
+
+With markdown extra it is possible to create reference style footnotes:
+
+```
+This is some text with a footnote.[^1]
+
+[^1]: And this is the footnote.
+```
+
+### Abbreviations
+
+With markdown extra you can add abbreviations to your markup. The use this functionality first create a definition list:
+
+```
+*[HTML]: Hyper Text Markup Language
+*[PHP]: Hypertext Preprocessor
+```
+
+Now markdown extra will convert all occurrences of `HTML` and `PHP` as follows:
+
+```
+HTML
+PHP
+```
diff --git a/plugins/rainlab/blog/assets/css/rainlab.blog-export.css b/plugins/rainlab/blog/assets/css/rainlab.blog-export.css
new file mode 100644
index 0000000..c0a42a7
--- /dev/null
+++ b/plugins/rainlab/blog/assets/css/rainlab.blog-export.css
@@ -0,0 +1,3 @@
+.export-behavior .export-columns {
+ max-height: 450px !important;
+}
diff --git a/plugins/rainlab/blog/assets/css/rainlab.blog-preview.css b/plugins/rainlab/blog/assets/css/rainlab.blog-preview.css
new file mode 100644
index 0000000..eb21587
--- /dev/null
+++ b/plugins/rainlab/blog/assets/css/rainlab.blog-preview.css
@@ -0,0 +1,85 @@
+.blog-post-preview .editor-preview .preview-content {
+ padding: 20px;
+}
+.blog-post-preview .editor-preview span.image-placeholder {
+ display: block;
+}
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone {
+ background: #ecf0f1;
+ display: block;
+ border: 1px solid #e5e9ec;
+ padding: 25px;
+ min-height: 123px;
+ position: relative;
+ text-align: center;
+ cursor: pointer;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone span.label {
+ color: #b1b9be;
+ font-size: 16px;
+ display: inline-block;
+ margin-top: 25px;
+}
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone:before {
+ display: inline-block;
+ font-family: FontAwesome;
+ font-weight: normal;
+ font-style: normal;
+ text-decoration: inherit;
+ -webkit-font-smoothing: antialiased;
+ *margin-right: .3em;
+ content: "\f03e";
+ position: absolute;
+ left: 25px;
+ top: 25px;
+ line-height: 100%;
+ font-size: 73px;
+ color: #d1d3d4;
+}
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone.hover,
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone:hover {
+ background: #2f99da;
+}
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone.hover:before,
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone:hover:before,
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone.hover span.label,
+.blog-post-preview .editor-preview span.image-placeholder .upload-dropzone:hover span.label {
+ color: white;
+}
+.blog-post-preview .editor-preview span.image-placeholder input[type=file] {
+ position: absolute;
+ left: -10000em;
+}
+.blog-post-preview-container .loading-indicator {
+ position: absolute;
+ display: none;
+ width: 20px;
+ height: 20px;
+ padding: 0!important;
+ background: transparent;
+ right: 10px;
+ left: auto;
+ top: 10px;
+}
+.blog-post-preview-container.loading-indicator-visible .loading-indicator {
+ display: block;
+}
+html.cssanimations .blog-post-preview span.image-placeholder.loading .upload-dropzone:before {
+ display: none;
+}
+html.cssanimations .blog-post-preview span.image-placeholder.loading .upload-dropzone .indicator {
+ display: block;
+ width: 50px;
+ height: 50px;
+ position: absolute;
+ left: 35px;
+ top: 35px;
+ background-image: url('../../../../../modules/system/assets/ui/images/loader-transparent.svg');
+ background-size: 50px 50px;
+ background-position: 50% 50%;
+ -webkit-animation: spin 1s linear infinite;
+ animation: spin 1s linear infinite;
+}
diff --git a/plugins/rainlab/blog/assets/images/blog-icon.svg b/plugins/rainlab/blog/assets/images/blog-icon.svg
new file mode 100644
index 0000000..ae6a619
--- /dev/null
+++ b/plugins/rainlab/blog/assets/images/blog-icon.svg
@@ -0,0 +1,30 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/blog/assets/js/post-form.js b/plugins/rainlab/blog/assets/js/post-form.js
new file mode 100644
index 0000000..cc5b866
--- /dev/null
+++ b/plugins/rainlab/blog/assets/js/post-form.js
@@ -0,0 +1,185 @@
++function ($) { "use strict";
+ var PostForm = function () {
+ this.$form = $('#post-form')
+ this.$markdownEditor = $('[data-field-name=content] [data-control=markdowneditor]:first', this.$form)
+ this.$preview = $('.editor-preview', this.$markdownEditor)
+
+ this.formAction = this.$form.attr('action')
+ this.sessionKey = $('input[name=_session_key]', this.$form).val()
+
+ if (this.$markdownEditor.length > 0) {
+ this.codeEditor = this.$markdownEditor.markdownEditor('getEditorObject')
+
+ this.$markdownEditor.on('initPreview.oc.markdowneditor', $.proxy(this.initPreview, this))
+
+ this.initDropzones()
+ this.initFormEvents()
+ this.addToolbarButton()
+ }
+
+ this.initLayout()
+ }
+
+ PostForm.prototype.addToolbarButton = function() {
+ this.buttonClickCount = 1
+
+ var self = this,
+ $button = this.$markdownEditor.markdownEditor('findToolbarButton', 'image')
+
+ if (!$button.length) return
+
+ $button.data('button-action', 'insertLine')
+ $button.data('button-template', '\n\n\n')
+
+ $button.on('click', function() {
+ $button.data('button-template', '\n\n\n')
+ self.buttonClickCount++
+ })
+ }
+
+ PostForm.prototype.initPreview = function() {
+ this.initImageUploaders()
+ }
+
+ PostForm.prototype.updateScroll = function() {
+ // Reserved in case MarkdownEditor uses scrollbar plugin
+ // this.$preview.data('oc.scrollbar').update()
+ }
+
+ PostForm.prototype.initImageUploaders = function() {
+ var self = this
+ $('span.image-placeholder .upload-dropzone', this.$preview).each(function(){
+ var
+ $placeholder = $(this).parent(),
+ $link = $('span.label', $placeholder),
+ placeholderIndex = $placeholder.data('index')
+
+ var uploaderOptions = {
+ url: self.formAction,
+ clickable: [$(this).get(0), $link.get(0)],
+ previewsContainer: $('').get(0),
+ paramName: 'file',
+ headers: {}
+ }
+
+ /*
+ * Add CSRF token to headers
+ */
+ var token = $('meta[name="csrf-token"]').attr('content')
+ if (token) {
+ uploaderOptions.headers['X-CSRF-TOKEN'] = token
+ }
+
+ var dropzone = new Dropzone($(this).get(0), uploaderOptions)
+
+ dropzone.on('error', function(file, error) {
+ alert('Error uploading file: ' + error)
+ })
+ dropzone.on('success', function(file, data){
+ if (data.error)
+ alert(data.error)
+ else {
+ self.pauseUpdates()
+ var $img = $('')
+ $img.load(function(){
+ self.updateScroll()
+ })
+
+ $placeholder.replaceWith($img)
+
+ self.codeEditor.replace('', {
+ needle: ''
+ })
+ self.resumeUpdates()
+ }
+ })
+ dropzone.on('complete', function(){
+ $placeholder.removeClass('loading')
+ })
+ dropzone.on('sending', function(file, xhr, formData) {
+ formData.append('X_BLOG_IMAGE_UPLOAD', 1)
+ formData.append('_session_key', self.sessionKey)
+ $placeholder.addClass('loading')
+ })
+ })
+ }
+
+ PostForm.prototype.pauseUpdates = function() {
+ this.$markdownEditor.markdownEditor('pauseUpdates')
+ }
+
+ PostForm.prototype.resumeUpdates = function() {
+ this.$markdownEditor.markdownEditor('resumeUpdates')
+ }
+
+ PostForm.prototype.initDropzones = function() {
+ $(document).bind('dragover', function (e) {
+ var dropZone = $('span.image-placeholder .upload-dropzone'),
+ foundDropzone,
+ timeout = window.dropZoneTimeout
+
+ if (!timeout)
+ dropZone.addClass('in');
+ else
+ clearTimeout(timeout);
+
+ var found = false,
+ node = e.target
+
+ do {
+ if ($(node).hasClass('dropzone')) {
+ found = true
+ foundDropzone = $(node)
+ break
+ }
+
+ node = node.parentNode;
+
+ } while (node != null);
+
+ dropZone.removeClass('in hover')
+
+ if (found)
+ foundDropzone.addClass('hover')
+
+ window.dropZoneTimeout = setTimeout(function () {
+ window.dropZoneTimeout = null
+ dropZone.removeClass('in hover')
+ }, 100)
+ })
+ }
+
+ PostForm.prototype.initFormEvents = function() {
+ $(document).on('ajaxSuccess', '#post-form', function(event, context, data){
+ if (context.handler == 'onSave' && !data.X_OCTOBER_ERROR_FIELDS) {
+ $(this).trigger('unchange.oc.changeMonitor')
+ }
+ })
+ }
+
+ PostForm.prototype.initLayout = function() {
+ $('#Form-secondaryTabs .tab-pane.layout-cell:not(:first-child)').addClass('padded-pane')
+ $('#Form-secondaryTabs .nav-tabs > li:not(:first-child)').addClass('tab-content-bg')
+ }
+
+ PostForm.prototype.replacePlaceholder = function(placeholder, placeholderHtmlReplacement, mdCodePlaceholder, mdCodeReplacement) {
+ this.pauseUpdates()
+ placeholder.replaceWith(placeholderHtmlReplacement)
+
+ this.codeEditor.replace(mdCodeReplacement, {
+ needle: mdCodePlaceholder
+ })
+ this.updateScroll()
+ this.resumeUpdates()
+ }
+
+ $(document).ready(function(){
+ var form = new PostForm()
+
+ if ($.oc === undefined)
+ $.oc = {}
+
+ $.oc.blogPostForm = form
+ })
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/blog/assets/less/rainlab.blog-preview.less b/plugins/rainlab/blog/assets/less/rainlab.blog-preview.less
new file mode 100644
index 0000000..95334a0
--- /dev/null
+++ b/plugins/rainlab/blog/assets/less/rainlab.blog-preview.less
@@ -0,0 +1,99 @@
+@import "../../../../../modules/backend/assets/less/core/boot.less";
+
+.blog-post-preview .editor-preview {
+ .preview-content {
+ padding: 20px;
+ }
+
+ span.image-placeholder {
+ display: block;
+
+ .upload-dropzone {
+ background: #ecf0f1;
+ display: block;
+ border: 1px solid #e5e9ec;
+ padding: 25px;
+ min-height: 123px;
+ position: relative;
+ text-align: center;
+ cursor: pointer;
+ .box-sizing(border-box);
+
+ span.label {
+ color: #b1b9be;
+ font-size: 16px;
+ display: inline-block;
+ margin-top: 25px;
+ }
+
+ &:before {
+ display: inline-block;
+ .icon(@picture-o);
+ position: absolute;
+ left: 25px;
+ top: 25px;
+ line-height: 100%;
+ font-size: 73px;
+ color: #d1d3d4;
+ }
+
+ &.hover, &:hover {
+ background: #2f99da;
+
+ &:before, span.label {
+ color: white;
+ }
+ }
+ }
+
+ input[type=file] {
+ position: absolute;
+ left: -10000em;
+ }
+ }
+}
+
+.blog-post-preview-container {
+ .loading-indicator {
+ position: absolute;
+ display: none;
+ width: 20px;
+ height: 20px;
+ padding: 0!important;
+ background: transparent;
+ right: 10px;
+ left: auto;
+ top: 10px;
+ }
+
+ &.loading-indicator-visible {
+ .loading-indicator {
+ display: block;
+ }
+ }
+}
+
+html.cssanimations {
+ .blog-post-preview {
+ span.image-placeholder.loading {
+ .upload-dropzone {
+ &:before {
+ display: none;
+ }
+
+ .indicator {
+ display: block;
+ width: 50px;
+ height: 50px;
+ position: absolute;
+ left: 35px;
+ top: 35px;
+ background-image:url('../../../../../modules/system/assets/ui/images/loader-transparent.svg');
+ background-size: 50px 50px;
+ background-position: 50% 50%;
+ .animation(spin 1s linear infinite);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/rainlab/blog/classes/TagProcessor.php b/plugins/rainlab/blog/classes/TagProcessor.php
new file mode 100644
index 0000000..d24f481
--- /dev/null
+++ b/plugins/rainlab/blog/classes/TagProcessor.php
@@ -0,0 +1,39 @@
+callbacks[] = $callback;
+ }
+
+ public function processTags($markup, $preview)
+ {
+ foreach ($this->callbacks as $callback) {
+ $markup = $callback($markup, $preview);
+ }
+
+ return $markup;
+ }
+}
diff --git a/plugins/rainlab/blog/components/Categories.php b/plugins/rainlab/blog/components/Categories.php
new file mode 100644
index 0000000..8caed92
--- /dev/null
+++ b/plugins/rainlab/blog/components/Categories.php
@@ -0,0 +1,113 @@
+ 'rainlab.blog::lang.settings.category_title',
+ 'description' => 'rainlab.blog::lang.settings.category_description'
+ ];
+ }
+
+ public function defineProperties()
+ {
+ return [
+ 'slug' => [
+ 'title' => 'rainlab.blog::lang.settings.category_slug',
+ 'description' => 'rainlab.blog::lang.settings.category_slug_description',
+ 'default' => '{{ :slug }}',
+ 'type' => 'string',
+ ],
+ 'displayEmpty' => [
+ 'title' => 'rainlab.blog::lang.settings.category_display_empty',
+ 'description' => 'rainlab.blog::lang.settings.category_display_empty_description',
+ 'type' => 'checkbox',
+ 'default' => 0,
+ ],
+ 'categoryPage' => [
+ 'title' => 'rainlab.blog::lang.settings.category_page',
+ 'description' => 'rainlab.blog::lang.settings.category_page_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/category',
+ 'group' => 'rainlab.blog::lang.settings.group_links',
+ ],
+ ];
+ }
+
+ public function getCategoryPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function onRun()
+ {
+ $this->currentCategorySlug = $this->page['currentCategorySlug'] = $this->property('slug');
+ $this->categoryPage = $this->page['categoryPage'] = $this->property('categoryPage');
+ $this->categories = $this->page['categories'] = $this->loadCategories();
+ }
+
+ /**
+ * Load all categories or, depending on the option, only those that have blog posts
+ * @return mixed
+ */
+ protected function loadCategories()
+ {
+ $categories = BlogCategory::with('posts_count')->getNested();
+ if (!$this->property('displayEmpty')) {
+ $iterator = function ($categories) use (&$iterator) {
+ return $categories->reject(function ($category) use (&$iterator) {
+ if ($category->getNestedPostCount() == 0) {
+ return true;
+ }
+ if ($category->children) {
+ $category->children = $iterator($category->children);
+ }
+ return false;
+ });
+ };
+ $categories = $iterator($categories);
+ }
+
+ /*
+ * Add a "url" helper attribute for linking to each category
+ */
+ return $this->linkCategories($categories);
+ }
+
+ /**
+ * Sets the URL on each category according to the defined category page
+ * @return void
+ */
+ protected function linkCategories($categories)
+ {
+ return $categories->each(function ($category) {
+ $category->setUrl($this->categoryPage, $this->controller);
+
+ if ($category->children) {
+ $this->linkCategories($category->children);
+ }
+ });
+ }
+}
diff --git a/plugins/rainlab/blog/components/Post.php b/plugins/rainlab/blog/components/Post.php
new file mode 100644
index 0000000..8faa8ff
--- /dev/null
+++ b/plugins/rainlab/blog/components/Post.php
@@ -0,0 +1,156 @@
+ 'rainlab.blog::lang.settings.post_title',
+ 'description' => 'rainlab.blog::lang.settings.post_description'
+ ];
+ }
+
+ public function defineProperties()
+ {
+ return [
+ 'slug' => [
+ 'title' => 'rainlab.blog::lang.settings.post_slug',
+ 'description' => 'rainlab.blog::lang.settings.post_slug_description',
+ 'default' => '{{ :slug }}',
+ 'type' => 'string',
+ ],
+ 'categoryPage' => [
+ 'title' => 'rainlab.blog::lang.settings.post_category',
+ 'description' => 'rainlab.blog::lang.settings.post_category_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/category',
+ ],
+ ];
+ }
+
+ public function getCategoryPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function init()
+ {
+ Event::listen('translate.localePicker.translateParams', function ($page, $params, $oldLocale, $newLocale) {
+ $newParams = $params;
+
+ if (isset($params['slug'])) {
+ $records = BlogPost::transWhere('slug', $params['slug'], $oldLocale)->first();
+ if ($records) {
+ $records->translateContext($newLocale);
+ $newParams['slug'] = $records['slug'];
+ }
+ }
+
+ return $newParams;
+ });
+ }
+
+ public function onRun()
+ {
+ $this->categoryPage = $this->page['categoryPage'] = $this->property('categoryPage');
+ $this->post = $this->page['post'] = $this->loadPost();
+ if (!$this->post) {
+ $this->setStatusCode(404);
+ return $this->controller->run('404');
+ }
+ }
+
+ public function onRender()
+ {
+ if (empty($this->post)) {
+ $this->post = $this->page['post'] = $this->loadPost();
+ }
+ }
+
+ protected function loadPost()
+ {
+ $slug = $this->property('slug');
+
+ $post = new BlogPost;
+ $query = $post->query();
+
+ if ($post->isClassExtendedWith('RainLab.Translate.Behaviors.TranslatableModel')) {
+ $query->transWhere('slug', $slug);
+ } else {
+ $query->where('slug', $slug);
+ }
+
+ if (!$this->checkEditor()) {
+ $query->isPublished();
+ }
+
+ $post = $query->first();
+
+ /*
+ * Add a "url" helper attribute for linking to each category
+ */
+ if ($post && $post->exists && $post->categories->count()) {
+ $post->categories->each(function($category) {
+ $category->setUrl($this->categoryPage, $this->controller);
+ });
+ }
+
+ return $post;
+ }
+
+ public function previousPost()
+ {
+ return $this->getPostSibling(-1);
+ }
+
+ public function nextPost()
+ {
+ return $this->getPostSibling(1);
+ }
+
+ protected function getPostSibling($direction = 1)
+ {
+ if (!$this->post) {
+ return;
+ }
+
+ $method = $direction === -1 ? 'previousPost' : 'nextPost';
+
+ if (!$post = $this->post->$method()) {
+ return;
+ }
+
+ $postPage = $this->getPage()->getBaseFileName();
+
+ $post->setUrl($postPage, $this->controller);
+
+ $post->categories->each(function($category) {
+ $category->setUrl($this->categoryPage, $this->controller);
+ });
+
+ return $post;
+ }
+
+ protected function checkEditor()
+ {
+ $backendUser = BackendAuth::getUser();
+
+ return $backendUser && $backendUser->hasAccess('rainlab.blog.access_posts');
+ }
+}
diff --git a/plugins/rainlab/blog/components/Posts.php b/plugins/rainlab/blog/components/Posts.php
new file mode 100644
index 0000000..3ac3c4a
--- /dev/null
+++ b/plugins/rainlab/blog/components/Posts.php
@@ -0,0 +1,259 @@
+ 'rainlab.blog::lang.settings.posts_title',
+ 'description' => 'rainlab.blog::lang.settings.posts_description'
+ ];
+ }
+
+ public function defineProperties()
+ {
+ return [
+ 'pageNumber' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_pagination',
+ 'description' => 'rainlab.blog::lang.settings.posts_pagination_description',
+ 'type' => 'string',
+ 'default' => '{{ :page }}',
+ ],
+ 'categoryFilter' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_filter',
+ 'description' => 'rainlab.blog::lang.settings.posts_filter_description',
+ 'type' => 'string',
+ 'default' => '',
+ ],
+ 'postsPerPage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_per_page',
+ 'type' => 'string',
+ 'validationPattern' => '^[0-9]+$',
+ 'validationMessage' => 'rainlab.blog::lang.settings.posts_per_page_validation',
+ 'default' => '10',
+ ],
+ 'noPostsMessage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_no_posts',
+ 'description' => 'rainlab.blog::lang.settings.posts_no_posts_description',
+ 'type' => 'string',
+ 'default' => Lang::get('rainlab.blog::lang.settings.posts_no_posts_default'),
+ 'showExternalParam' => false,
+ ],
+ 'sortOrder' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_order',
+ 'description' => 'rainlab.blog::lang.settings.posts_order_description',
+ 'type' => 'dropdown',
+ 'default' => 'published_at desc',
+ ],
+ 'categoryPage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_category',
+ 'description' => 'rainlab.blog::lang.settings.posts_category_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/category',
+ 'group' => 'rainlab.blog::lang.settings.group_links',
+ ],
+ 'postPage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_post',
+ 'description' => 'rainlab.blog::lang.settings.posts_post_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/post',
+ 'group' => 'rainlab.blog::lang.settings.group_links',
+ ],
+ 'exceptPost' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_except_post',
+ 'description' => 'rainlab.blog::lang.settings.posts_except_post_description',
+ 'type' => 'string',
+ 'validationPattern' => '^[a-z0-9\-_,\s]+$',
+ 'validationMessage' => 'rainlab.blog::lang.settings.posts_except_post_validation',
+ 'default' => '',
+ 'group' => 'rainlab.blog::lang.settings.group_exceptions',
+ ],
+ 'exceptCategories' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_except_categories',
+ 'description' => 'rainlab.blog::lang.settings.posts_except_categories_description',
+ 'type' => 'string',
+ 'validationPattern' => '^[a-z0-9\-_,\s]+$',
+ 'validationMessage' => 'rainlab.blog::lang.settings.posts_except_categories_validation',
+ 'default' => '',
+ 'group' => 'rainlab.blog::lang.settings.group_exceptions',
+ ],
+ ];
+ }
+
+ public function getCategoryPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function getPostPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function getSortOrderOptions()
+ {
+ $options = BlogPost::$allowedSortingOptions;
+
+ foreach ($options as $key => $value) {
+ $options[$key] = Lang::get($value);
+ }
+
+ return $options;
+ }
+
+ public function onRun()
+ {
+ $this->prepareVars();
+
+ $this->category = $this->page['category'] = $this->loadCategory();
+ $this->posts = $this->page['posts'] = $this->listPosts();
+
+ /*
+ * If the page number is not valid, redirect
+ */
+ if ($pageNumberParam = $this->paramName('pageNumber')) {
+ $currentPage = $this->property('pageNumber');
+
+ if ($currentPage > ($lastPage = $this->posts->lastPage()) && $currentPage > 1) {
+ return Redirect::to($this->currentPageUrl([$pageNumberParam => $lastPage]));
+ }
+ }
+ }
+
+ protected function prepareVars()
+ {
+ $this->pageParam = $this->page['pageParam'] = $this->paramName('pageNumber');
+ $this->noPostsMessage = $this->page['noPostsMessage'] = $this->property('noPostsMessage');
+
+ /*
+ * Page links
+ */
+ $this->postPage = $this->page['postPage'] = $this->property('postPage');
+ $this->categoryPage = $this->page['categoryPage'] = $this->property('categoryPage');
+ }
+
+ protected function listPosts()
+ {
+ $category = $this->category ? $this->category->id : null;
+ $categorySlug = $this->category ? $this->category->slug : null;
+
+ /*
+ * List all the posts, eager load their categories
+ */
+ $isPublished = !$this->checkEditor();
+
+ $posts = BlogPost::with(['categories', 'featured_images'])->listFrontEnd([
+ 'page' => $this->property('pageNumber'),
+ 'sort' => $this->property('sortOrder'),
+ 'perPage' => $this->property('postsPerPage'),
+ 'search' => trim(input('search')),
+ 'category' => $category,
+ 'published' => $isPublished,
+ 'exceptPost' => is_array($this->property('exceptPost'))
+ ? $this->property('exceptPost')
+ : preg_split('/,\s*/', $this->property('exceptPost'), -1, PREG_SPLIT_NO_EMPTY),
+ 'exceptCategories' => is_array($this->property('exceptCategories'))
+ ? $this->property('exceptCategories')
+ : preg_split('/,\s*/', $this->property('exceptCategories'), -1, PREG_SPLIT_NO_EMPTY),
+ ]);
+
+ /*
+ * Add a "url" helper attribute for linking to each post and category
+ */
+ $posts->each(function($post) use ($categorySlug) {
+ $post->setUrl($this->postPage, $this->controller, ['category' => $categorySlug]);
+
+ $post->categories->each(function($category) {
+ $category->setUrl($this->categoryPage, $this->controller);
+ });
+ });
+
+ return $posts;
+ }
+
+ protected function loadCategory()
+ {
+ if (!$slug = $this->property('categoryFilter')) {
+ return null;
+ }
+
+ $category = new BlogCategory;
+
+ $category = $category->isClassExtendedWith('RainLab.Translate.Behaviors.TranslatableModel')
+ ? $category->transWhere('slug', $slug)
+ : $category->where('slug', $slug);
+
+ $category = $category->first();
+
+ return $category ?: null;
+ }
+
+ protected function checkEditor()
+ {
+ $backendUser = BackendAuth::getUser();
+
+ return $backendUser &&
+ $backendUser->hasAccess('rainlab.blog.access_posts') &&
+ BlogSettings::get('show_all_posts', true);
+ }
+}
diff --git a/plugins/rainlab/blog/components/RssFeed.php b/plugins/rainlab/blog/components/RssFeed.php
new file mode 100644
index 0000000..295433b
--- /dev/null
+++ b/plugins/rainlab/blog/components/RssFeed.php
@@ -0,0 +1,159 @@
+ 'rainlab.blog::lang.settings.rssfeed_title',
+ 'description' => 'rainlab.blog::lang.settings.rssfeed_description'
+ ];
+ }
+
+ public function defineProperties()
+ {
+ return [
+ 'categoryFilter' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_filter',
+ 'description' => 'rainlab.blog::lang.settings.posts_filter_description',
+ 'type' => 'string',
+ 'default' => '',
+ ],
+ 'sortOrder' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_order',
+ 'description' => 'rainlab.blog::lang.settings.posts_order_description',
+ 'type' => 'dropdown',
+ 'default' => 'created_at desc',
+ ],
+ 'postsPerPage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_per_page',
+ 'type' => 'string',
+ 'validationPattern' => '^[0-9]+$',
+ 'validationMessage' => 'rainlab.blog::lang.settings.posts_per_page_validation',
+ 'default' => '10',
+ ],
+ 'blogPage' => [
+ 'title' => 'rainlab.blog::lang.settings.rssfeed_blog',
+ 'description' => 'rainlab.blog::lang.settings.rssfeed_blog_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/post',
+ 'group' => 'rainlab.blog::lang.settings.group_links',
+ ],
+ 'postPage' => [
+ 'title' => 'rainlab.blog::lang.settings.posts_post',
+ 'description' => 'rainlab.blog::lang.settings.posts_post_description',
+ 'type' => 'dropdown',
+ 'default' => 'blog/post',
+ 'group' => 'rainlab.blog::lang.settings.group_links',
+ ],
+ ];
+ }
+
+ public function getBlogPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function getPostPageOptions()
+ {
+ return Page::sortBy('baseFileName')->lists('baseFileName', 'baseFileName');
+ }
+
+ public function getSortOrderOptions()
+ {
+ $options = BlogPost::$allowedSortingOptions;
+
+ foreach ($options as $key => $value) {
+ $options[$key] = Lang::get($value);
+ }
+
+ return $options;
+ }
+
+ public function onRun()
+ {
+ $this->prepareVars();
+
+ $xmlFeed = $this->renderPartial('@default');
+
+ return Response::make($xmlFeed, '200')->header('Content-Type', 'text/xml');
+ }
+
+ protected function prepareVars()
+ {
+ $this->blogPage = $this->page['blogPage'] = $this->property('blogPage');
+ $this->postPage = $this->page['postPage'] = $this->property('postPage');
+ $this->category = $this->page['category'] = $this->loadCategory();
+ $this->posts = $this->page['posts'] = $this->listPosts();
+
+ $this->page['link'] = $this->pageUrl($this->blogPage);
+ $this->page['rssLink'] = $this->currentPageUrl();
+ }
+
+ protected function listPosts()
+ {
+ $category = $this->category ? $this->category->id : null;
+
+ /*
+ * List all the posts, eager load their categories
+ */
+ $posts = BlogPost::with('categories')->listFrontEnd([
+ 'sort' => $this->property('sortOrder'),
+ 'perPage' => $this->property('postsPerPage'),
+ 'category' => $category
+ ]);
+
+ /*
+ * Add a "url" helper attribute for linking to each post and category
+ */
+ $posts->each(function($post) {
+ $post->setUrl($this->postPage, $this->controller);
+ });
+
+ return $posts;
+ }
+
+ protected function loadCategory()
+ {
+ if (!$categoryId = $this->property('categoryFilter')) {
+ return null;
+ }
+
+ if (!$category = BlogCategory::whereSlug($categoryId)->first()) {
+ return null;
+ }
+
+ return $category;
+ }
+}
diff --git a/plugins/rainlab/blog/components/categories/default.htm b/plugins/rainlab/blog/components/categories/default.htm
new file mode 100644
index 0000000..e9b0280
--- /dev/null
+++ b/plugins/rainlab/blog/components/categories/default.htm
@@ -0,0 +1,10 @@
+{% if __SELF__.categories|length > 0 %}
+
+
+
diff --git a/plugins/rainlab/blog/formwidgets/BlogMarkdown.php b/plugins/rainlab/blog/formwidgets/BlogMarkdown.php
new file mode 100644
index 0000000..caff718
--- /dev/null
+++ b/plugins/rainlab/blog/formwidgets/BlogMarkdown.php
@@ -0,0 +1,133 @@
+viewPath = base_path().'/modules/backend/formwidgets/markdowneditor/partials';
+
+ $this->checkUploadPostback();
+
+ parent::init();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function loadAssets()
+ {
+ $this->assetPath = '/modules/backend/formwidgets/markdowneditor/assets';
+ parent::loadAssets();
+ }
+
+ /**
+ * Disable HTML cleaning on the widget level since the PostModel will handle it
+ *
+ * @return boolean
+ */
+ protected function shouldCleanHtml()
+ {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function onRefresh()
+ {
+ $content = post($this->formField->getName());
+
+ $previewHtml = PostModel::formatHtml($content, true);
+
+ return [
+ 'preview' => $previewHtml
+ ];
+ }
+
+ /**
+ * Handle images being uploaded to the blog post
+ *
+ * @return void
+ */
+ protected function checkUploadPostback()
+ {
+ if (!post('X_BLOG_IMAGE_UPLOAD')) {
+ return;
+ }
+
+ $uploadedFileName = null;
+
+ try {
+ $uploadedFile = Input::file('file');
+
+ if ($uploadedFile)
+ $uploadedFileName = $uploadedFile->getClientOriginalName();
+
+ $validationRules = ['max:'.File::getMaxFilesize()];
+ $validationRules[] = 'mimes:jpg,jpeg,bmp,png,gif';
+
+ $validation = Validator::make(
+ ['file_data' => $uploadedFile],
+ ['file_data' => $validationRules]
+ );
+
+ if ($validation->fails()) {
+ throw new ValidationException($validation);
+ }
+
+ if (!$uploadedFile->isValid()) {
+ throw new SystemException(Lang::get('cms::lang.asset.file_not_valid'));
+ }
+
+ $fileRelation = $this->model->content_images();
+
+ $file = new File();
+ $file->data = $uploadedFile;
+ $file->is_public = true;
+ $file->save();
+
+ $fileRelation->add($file, $this->sessionKey);
+ $result = [
+ 'file' => $uploadedFileName,
+ 'path' => $file->getPath()
+ ];
+
+ $response = Response::make()->setContent($result);
+ $this->controller->setResponse($response);
+
+ } catch (Exception $ex) {
+ $message = $uploadedFileName
+ ? Lang::get('cms::lang.asset.error_uploading_file', ['name' => $uploadedFileName, 'error' => $ex->getMessage()])
+ : $ex->getMessage();
+
+ $result = [
+ 'error' => $message,
+ 'file' => $uploadedFileName
+ ];
+
+ $response = Response::make()->setContent($result);
+ $this->controller->setResponse($response);
+ }
+ }
+}
diff --git a/plugins/rainlab/blog/formwidgets/MLBlogMarkdown.php b/plugins/rainlab/blog/formwidgets/MLBlogMarkdown.php
new file mode 100644
index 0000000..5da6fa9
--- /dev/null
+++ b/plugins/rainlab/blog/formwidgets/MLBlogMarkdown.php
@@ -0,0 +1,137 @@
+initLocale();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function render()
+ {
+ $this->actAsParent();
+ $parentContent = parent::render();
+ $this->actAsParent(false);
+
+ if (!$this->isAvailable) {
+ return $parentContent;
+ }
+
+ $this->vars['markdowneditor'] = $parentContent;
+
+ $this->actAsControl(true);
+
+ return $this->makePartial('mlmarkdowneditor');
+ }
+
+ public function prepareVars()
+ {
+ parent::prepareVars();
+ $this->prepareLocaleVars();
+ }
+
+ /**
+ * Returns an array of translated values for this field
+ * @param $value
+ * @return array
+ */
+ public function getSaveValue($value)
+ {
+ $localeData = $this->getLocaleSaveData();
+
+ /*
+ * Set the translated values to the model
+ */
+ if ($this->model->methodExists('setAttributeTranslated')) {
+ foreach ($localeData as $locale => $value) {
+ $this->model->setAttributeTranslated('content', $value, $locale);
+
+ $this->model->setAttributeTranslated(
+ 'content_html',
+ Post::formatHtml($value),
+ $locale
+ );
+ }
+ }
+
+ return array_get($localeData, $this->defaultLocale->code, $value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function loadAssets()
+ {
+ $this->actAsParent();
+ parent::loadAssets();
+ $this->actAsParent(false);
+
+ if (Locale::isAvailable()) {
+ $this->loadLocaleAssets();
+
+ $this->actAsControl(true);
+ $this->addJs('js/mlmarkdowneditor.js');
+ $this->actAsControl(false);
+ }
+ }
+
+ protected function actAsParent($switch = true)
+ {
+ if ($switch) {
+ $this->originalAssetPath = $this->assetPath;
+ $this->originalViewPath = $this->viewPath;
+ $this->assetPath = '/modules/backend/formwidgets/markdowneditor/assets';
+ $this->viewPath = base_path('/modules/backend/formwidgets/markdowneditor/partials');
+ }
+ else {
+ $this->assetPath = $this->originalAssetPath;
+ $this->viewPath = $this->originalViewPath;
+ }
+ }
+
+ protected function actAsControl($switch = true)
+ {
+ if ($switch) {
+ $this->originalAssetPath = $this->assetPath;
+ $this->originalViewPath = $this->viewPath;
+ $this->assetPath = '/plugins/rainlab/translate/formwidgets/mlmarkdowneditor/assets';
+ $this->viewPath = base_path('/plugins/rainlab/translate/formwidgets/mlmarkdowneditor/partials');
+ }
+ else {
+ $this->assetPath = $this->originalAssetPath;
+ $this->viewPath = $this->originalViewPath;
+ }
+ }
+}
diff --git a/plugins/rainlab/blog/lang/bg/lang.php b/plugins/rainlab/blog/lang/bg/lang.php
new file mode 100644
index 0000000..6e5161d
--- /dev/null
+++ b/plugins/rainlab/blog/lang/bg/lang.php
@@ -0,0 +1,100 @@
+ [
+ 'name' => 'Блог',
+ 'description' => 'Стабилната блог платформа.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Блог',
+ 'menu_description' => 'управление на публикациите',
+ 'posts' => 'публикации',
+ 'create_post' => 'създай публикация',
+ 'categories' => 'категории',
+ 'create_category' => 'създай категория',
+ 'tab' => 'Блог',
+ 'access_posts' => 'управление на публикациите',
+ 'access_categories' => 'управление на категории',
+ 'access_other_posts' => 'управление на други потребители публикации в блога',
+ 'delete_confirm' => 'Сигурни ли сте?',
+ 'chart_published' => 'Публикувано',
+ 'chart_drafts' => 'Чернови',
+ 'chart_total' => 'Общо'
+ ],
+ 'posts' => [
+ 'list_title' => 'Управление публикациите в блога',
+ 'filter_category' => 'Категория',
+ 'filter_published' => 'Скрий публикуваните',
+ 'new_post' => 'Нова публикация'
+ ],
+ 'post' => [
+ 'title' => 'Заглавие',
+ 'title_placeholder' => 'Ново заглавие на публикацията',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'нов slug на публикацията',
+ 'categories' => 'Категории',
+ 'created' => 'Създаден',
+ 'updated' => 'Обновен',
+ 'published' => 'Публикуван',
+ 'published_validation' => 'Моля, посочете дата на публикуване',
+ 'tab_edit' => 'Промяна',
+ 'tab_categories' => 'Категории',
+ 'categories_comment' => 'Изберете категории към който пренадлежи публикацията ',
+ 'categories_placeholder' => 'Няма категирии, Създайте първата?!',
+ 'tab_manage' => 'Управление',
+ 'published_on' => 'публикувано в',
+ 'excerpt' => 'Откъс',
+ 'featured_images' => 'Избрани снимки',
+ 'delete_confirm' => 'Наистина ли искате да изтриете тази публикация?',
+ 'close_confirm' => 'Публикацията не е запазена.',
+ 'return_to_posts' => 'Върни ме към всички публикации'
+ ],
+ 'categories' => [
+ 'list_title' => 'Управление категориите в блога',
+ 'new_category' => 'Нова категория',
+ 'uncategorized' => 'Без категория'
+ ],
+ 'category' => [
+ 'name' => 'Име',
+ 'name_placeholder' => 'Ново име на категорията',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'нов slug на категотията',
+ 'posts' => 'публикации',
+ 'delete_confirm' => 'Наистина ли искате да изтриете тази категория?',
+ 'return_to_categories' => 'Върни ме към всички категории'
+ ],
+ 'settings' => [
+ 'category_title' => 'Списък с категории',
+ 'category_description' => 'Показва списък с категориите на блога.',
+ 'category_slug' => 'категория slug',
+ 'category_slug_description' => "Look up the blog category using the supplied slug value. This property is used by the default component partial for marking the currently active category.",
+ 'category_display_empty' => 'Показване на празни категории',
+ 'category_display_empty_description' => 'Показване на категории, които нямат никакви публикации.',
+ 'category_page' => 'Страница на категория',
+ 'category_page_description' => 'Име на страницата за категирия. Това се използва подразбиране от компонента.',
+ 'post_title' => 'Публикация',
+ 'post_description' => 'Показване на Публикациите в блога на страницата.',
+ 'post_slug' => 'Post slug',
+ 'post_slug_description' => "Търсене на публикации по зададен slug.",
+ 'post_category' => 'Страница за Категория',
+ 'post_category_description' => 'Име на страница за категория за генериране на линк.Това се използва подразбиране от компонента.',
+ 'posts_title' => 'Лист с Публикации',
+ 'posts_description' => 'Показване на лист с публикации на страницата.',
+ 'posts_pagination' => 'Номер на страницата',
+ 'posts_pagination_description' => 'Тази стойност се използва за определяне на коя страница е потребителя.',
+ 'posts_filter' => 'Филтер Категория',
+ 'posts_filter_description' => 'Въведи slug на категория или URL адрес за филтриране по. Оставете празно за да се покажат всички публикации.',
+ 'posts_per_page' => 'Публикации на страница',
+ 'posts_per_page_validation' => 'Невалиден формат за публикации на страница',
+ 'posts_no_posts' => 'Няма публикации',
+ 'posts_no_posts_description' => 'Съобщение което да се покаже, в случай ,че няма публикации за показване.Това се използва подразбиране от компонента.',
+ 'posts_order' => 'подреждане на публикации',
+ 'posts_order_description' => 'Атрибут по който да бъдат подредени публикациите',
+ 'posts_category' => 'страница на категориите',
+ 'posts_category_description' => 'Име на страницата за категории , за "публикувано в". Това се използва подразбиране от компонента.',
+ 'posts_post' => 'Post page',
+ 'posts_post_description' => 'Име на страницата за публикации "Прочетете повече". Това се използва подразбиране от компонента.',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/cs/lang.php b/plugins/rainlab/blog/lang/cs/lang.php
new file mode 100644
index 0000000..553d125
--- /dev/null
+++ b/plugins/rainlab/blog/lang/cs/lang.php
@@ -0,0 +1,154 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Robustní blogová platforma.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Správa blogových příspěvků',
+ 'posts' => 'Příspěvky',
+ 'create_post' => 'Příspěvek',
+ 'categories' => 'Kategorie',
+ 'create_category' => 'Kategorie příspěvků',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Správa blogových příspěvků',
+ 'access_categories' => 'Správa blogových kategorií',
+ 'access_other_posts' => 'Správa příspěvků ostatních uživatelů',
+ 'access_import_export' => 'Možnost importu a exportu příspěvků',
+ 'access_publish' => 'Možnost publikovat příspěvky',
+ 'delete_confirm' => 'Jste si jistí?',
+ 'chart_published' => 'Publikované',
+ 'chart_drafts' => 'Návrhy',
+ 'chart_total' => 'Celkem',
+ ],
+ 'posts' => [
+ 'list_title' => 'Správa blogových příspěvků',
+ 'filter_category' => 'Kategorie',
+ 'filter_published' => 'Schovat publikované',
+ 'filter_date' => 'Datum',
+ 'new_post' => 'Nový příspěvek',
+ 'export_post' => 'Export příspěvků',
+ 'import_post' => 'Import příspěvků',
+ ],
+ 'post' => [
+ 'title' => 'Název',
+ 'title_placeholder' => 'Zadejte název',
+ 'content' => 'Obsah',
+ 'content_html' => 'HTML obsah',
+ 'slug' => 'URL příspěvku',
+ 'slug_placeholder' => 'zadejte-url-prispevku',
+ 'categories' => 'Kategorie',
+ 'author_email' => 'E-mail autora',
+ 'created' => 'Vytvořeno',
+ 'created_date' => 'Vytvořeno dne',
+ 'updated' => 'Upraveno',
+ 'updated_date' => 'Upraveno dne',
+ 'published' => 'Publikováno',
+ 'published_date' => 'Publikováno dne',
+ 'published_validation' => 'Zadejte prosím datum publikace příspěvku',
+ 'tab_edit' => 'Upravit',
+ 'tab_categories' => 'Kategorie',
+ 'categories_comment' => 'Vyberte kategorie do kterých příspěvek patří',
+ 'categories_placeholder' => 'Nejsou zde žádné kategorie, nejdříve musíte nějaké vytvořit!',
+ 'tab_manage' => 'Nastavení',
+ 'published_on' => 'Publikováno dne',
+ 'excerpt' => 'Perex příspěvku',
+ 'summary' => 'Shrnutí',
+ 'featured_images' => 'Obrázky',
+ 'delete_confirm' => 'Opravdu chcete smazat tento příspěvek?',
+ 'delete_success' => 'Vybrané příspěvky úspěšně odstraněny.',
+ 'close_confirm' => 'Příspěvek není uložený.',
+ 'return_to_posts' => 'Zpět na seznam příspěvků',
+ ],
+ 'categories' => [
+ 'list_title' => 'Správa blogových kategorií',
+ 'new_category' => 'Nová kategorie',
+ 'uncategorized' => 'Nezařazeno',
+ ],
+ 'category' => [
+ 'name' => 'Název',
+ 'name_placeholder' => 'Název nové kategorie',
+ 'description' => 'Popis',
+ 'slug' => 'URL kategorie',
+ 'slug_placeholder' => 'zadejte-url-kategorie',
+ 'posts' => 'Počet příspěvků',
+ 'delete_confirm' => 'Opravdu chcete smazat tuto kategorii?',
+ 'delete_success' => 'Vybrané kategorie úspěšně odstraněny.',
+ 'return_to_categories' => 'Zpět na seznam blogových kategorií',
+ 'reorder' => 'Změnit pořadí',
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blogová kategorie',
+ 'all_blog_categories' => 'Všechny blogové kategorie',
+ 'blog_post' => 'Blogový příspěvek',
+ 'all_blog_posts' => 'Všechny blogové příspěvky',
+ 'category_blog_posts' => 'Blog category posts'
+ ],
+ 'settings' => [
+ 'category_title' => 'Seznam kategorií',
+ 'category_description' => 'Zobrazí na stránce seznam blogových kategorií.',
+ 'category_slug' => 'URL kategorie',
+ 'category_slug_description' => "Najde blogovou kategorii s tímto URL. Používá se pro zobrazení aktivní kategorie.",
+ 'category_display_empty' => 'Zobrazit prázdné kategorie',
+ 'category_display_empty_description' => 'Zobrazit kategorie bez blogových příspěvků.',
+ 'category_page' => 'Stránka kategorií',
+ 'category_page_description' => 'Vyberte stránku která slouží k zobrazení všech kategorií (nebo detailu kategorie).',
+ 'post_title' => 'Příspěvek',
+ 'post_description' => 'Zobrazí blogový příspěvek na stránce.',
+ 'post_slug' => 'URL příspěvku',
+ 'post_slug_description' => "Najde příspěvek dle zadané URL.",
+ 'post_category' => 'Stránka kategorie',
+ 'post_category_description' => 'Vyberte stránku která slouží k zobrazení všech kategorií (nebo detailu kategorie).',
+ 'posts_title' => 'Seznam příspěvků',
+ 'posts_description' => 'Zobrazí na stránce seznam posledních příspěvků na stránkách.',
+ 'posts_pagination' => 'Číslo stránky',
+ 'posts_pagination_description' => 'Číslo stránky určující na které stránce se uživatel nachází. Použito pro stránkování.',
+ 'posts_filter' => 'Filtr kategorií',
+ 'posts_filter_description' => 'Zadejte URL kategorie, nebo URL parametr pro filtrování příspěvků. Nechte prázdné pro zobrazení všech příspěvků.',
+ 'posts_per_page' => 'Příspěvků na stránku',
+ 'posts_per_page_validation' => 'Špatný formát počtu příspěvků na stránku, musí být zadáno jako číslo',
+ 'posts_no_posts' => 'Hláška prázdné stránky',
+ 'posts_no_posts_description' => 'Zpráva se zobrazí pokud se nepovede najít žádné články.',
+ 'posts_no_posts_default' => 'Nenalezeny žádné příspěvky',
+ 'posts_order' => 'Řazení článků',
+ 'posts_order_decription' => 'Nastaví řazení článků ve výpisu',
+ 'posts_category' => 'Stránka kategorií',
+ 'posts_category_description' => 'Vyberte stránku která slouží k zobrazení všech kategorií (nebo detailu kategorie).',
+ 'posts_post' => 'Stránka příspěvků',
+ 'posts_post_description' => 'Vyberte stránku která slouží k zobrazení článků (nebo detailu článku).',
+ 'posts_except_post' => 'Vyloučit příspěvěk',
+ 'posts_except_post_description' => 'Zadejte ID nebo URL příspěvku který chcete vyloučit',
+ 'posts_except_categories' => 'Vyloučené kategorie',
+ 'posts_except_categories_description' => 'Pro vyloučení kategorií zadejte čárkou oddělené URL příspěvků nebo proměnnou, která tento seznam obsahuje.',
+ 'rssfeed_blog' => 'Blogová stránka',
+ 'rssfeed_blog_description' => 'Name of the main blog page file for generating links. This property is used by the default component partial.',
+ 'rssfeed_title' => 'RSS Kanál',
+ 'rssfeed_description' => 'Vygeneruje RSS kanál který obsahuje blogové příspěvky.',
+ 'group_links' => 'Odkazy',
+ 'group_exceptions' => 'Výjimky'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Název (sestupně)',
+ 'title_desc' => 'Název (vzestupně)',
+ 'created_asc' => 'Vytvořeno (sestupně)',
+ 'created_desc' => 'Vytvořeno (vzestupně)',
+ 'updated_asc' => 'Upraveno (sestupně)',
+ 'updated_desc' => 'Upraveno (vzestupně)',
+ 'published_asc' => 'Publikováno (sestupně)',
+ 'published_desc' => 'Publikováno (vzestupně)',
+ 'random' => 'Náhodně'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Uprav existující příspěvky',
+ 'update_existing_comment' => 'Zvolte pokud chcete upravit příspěvky se stejným ID, názvem nebo URL.',
+ 'auto_create_categories_label' => 'VYtvořit kategorie ze souboru',
+ 'auto_create_categories_comment' => 'Chcete-li tuto funkci použít, měli byste se shodovat se sloupcem Kategorie, jinak vyberte výchozí kategorie, které chcete použít z níže uvedených položek.',
+ 'categories_label' => 'Kategorie',
+ 'categories_comment' => 'Vyberte kategorie ke kterým budou příspěvky přiřazeny (volitelné).',
+ 'default_author_label' => 'Výchozí autor příspěvků (volitelné)',
+ 'default_author_comment' => 'Import se pokusí použít existujícího autora, pokud odpovídá sloupci email, jinak se použije výše uvedený autor.',
+ 'default_author_placeholder' => '-- vyberte autora --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/de/lang.php b/plugins/rainlab/blog/lang/de/lang.php
new file mode 100644
index 0000000..7e78057
--- /dev/null
+++ b/plugins/rainlab/blog/lang/de/lang.php
@@ -0,0 +1,132 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Eine robuste Blog Plattform.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Blog Artikel bearbeiten',
+ 'posts' => 'Artikel',
+ 'create_post' => 'Blog Artikel',
+ 'categories' => 'Kategorien',
+ 'create_category' => 'Blog Kategorie',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Blog Artikel verwalten',
+ 'access_categories' => 'Blog Kategorien verwalten',
+ 'access_other_posts' => 'Blog Artikel anderer Benutzer verwalten',
+ 'access_import_export' => 'Blog Artikel importieren oder exportieren',
+ 'access_publish' => 'Kann Artikel veröffentlichen',
+ 'delete_confirm' => 'Bist du sicher?',
+ 'chart_published' => 'Veröffentlicht',
+ 'chart_drafts' => 'Entwurf',
+ 'chart_total' => 'Gesamt'
+ ],
+ 'posts' => [
+ 'list_title' => 'Blog Artikel verwalten',
+ 'filter_category' => 'Kategorie',
+ 'filter_published' => 'Veröffentlichte ausblenden',
+ 'filter_date' => 'Date',
+ 'new_post' => 'Neuer Artikel',
+ 'export_post' => 'Exportiere Artikel',
+ 'import_post' => 'Importiere Artikel'
+ ],
+ 'post' => [
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Neuer Titel',
+ 'content' => 'Inhalt',
+ 'content_html' => 'HTML-Inhalt',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'neuer-artikel-slug',
+ 'categories' => 'Kategorien',
+ 'author_email' => 'Autor E-Mail',
+ 'created' => 'Erstellt',
+ 'created_date' => 'Erstellzeitpunkt',
+ 'updated' => 'Aktualisiert',
+ 'updated_date' => 'Aktualisierungszeitpunk',
+ 'published' => 'Veröffentlicht',
+ 'published_date' => 'Veröffentlichungszeitpunkt',
+ 'published_validation' => 'Bitte gebe das Datum der Veröffentlichung an',
+ 'tab_edit' => 'Bearbeiten',
+ 'tab_categories' => 'Kategorien',
+ 'categories_comment' => 'Wähle die zugehörigen Kategorien',
+ 'categories_placeholder' => 'Es existieren keine Kategorien. Bitte lege zuerst Kategorien an!',
+ 'tab_manage' => 'Verwalten',
+ 'published_on' => 'Veröffentlicht am',
+ 'excerpt' => 'Textauszug',
+ 'summary' => 'Zusammenfassung',
+ 'featured_images' => 'Zugehörige Bilder',
+ 'delete_confirm' => 'Möchtest du diesen Artikel wirklich löschen?',
+ 'close_confirm' => 'Der Artikel ist noch nicht gespeichert.',
+ 'return_to_posts' => 'Zurück zur Artikel-Übersicht',
+ 'posted_byline' => 'Veröffentlicht in :categories am :date',
+ 'posted_byline_no_categories' => 'Veröffentlicht am :date',
+ 'date_format' => 'd. F Y',
+ ],
+ 'categories' => [
+ 'list_title' => 'Blog Kategorien verwalten',
+ 'new_category' => 'Neue Kategorie',
+ 'uncategorized' => 'Allgemein'
+ ],
+ 'category' => [
+ 'name' => 'Name',
+ 'name_placeholder' => 'Neuer Kategorie Name',
+ 'description' => 'Beschreibung',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'neuer-kategorie-slug',
+ 'posts' => 'Artikel',
+ 'delete_confirm' => 'Möchtest du die Kategorie wirklich löschen?',
+ 'return_to_categories' => 'Zurück zur Kategorie-Übersicht.',
+ 'reorder' => 'Kategorien sortieren'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog Kategorie',
+ 'all_blog_categories' => 'Alle Blog Kategorien',
+ 'blog_post' => 'Blog Artikel',
+ 'all_blog_posts' => 'Alle Blog Artikel',
+ 'category_blog_posts' => 'Blog Kategorie Artikel'
+ ],
+ 'settings' => [
+ 'category_title' => 'Blog Kategorie-Übersicht',
+ 'category_description' => 'Zeigt eine Blog Kategorien-Übersicht.',
+ 'category_slug' => 'Slug Parametername',
+ 'category_slug_description' => 'Der URL-Routen-Parameter welcher verwendet wird um die aktuelle Kategorie zu bestimmen. Wird von der Standard-Komponente benötigt um die aktive Kategorie zu markieren.',
+ 'category_display_empty' => 'Leere Kategorien anzeigen',
+ 'category_display_empty_description' => 'Kategorien zeigen welche keine Artikel besitzen.',
+ 'category_page' => 'Kategorien Seite',
+ 'category_page_description' => 'Name der Kategorien-Seiten-Datei für die Kategorien Links. Wird von der Standard-Komponente benötigt.',
+ 'post_title' => 'Blog Artikel',
+ 'post_description' => 'Zeigt einen Blog Artikel auf der Seite.',
+ 'post_slug' => 'Slug Parametername',
+ 'post_slug_description' => 'Der URL-Routen-Parameter um den Post mittels "Slug" zu bestimmen.',
+ 'post_category' => 'Kategorien-Seite',
+ 'post_category_description' => 'Name der Kategorien-Seiten-Datei für Kategorie-Links.',
+ 'posts_title' => 'Blog Artikel-Übersicht',
+ 'posts_description' => 'Stellt eine Liste der neuesten Artikel auf der Seite dar.',
+ 'posts_pagination' => 'Blättern Parametername',
+ 'posts_pagination_description' => 'Der erwartete Parametername welcher für Seiten verwendet wird.',
+ 'posts_filter' => 'Kategorien-Filter',
+ 'posts_filter_description' => 'Bitte gebe ein Kategorien-Slug oder URL-Parameter an, mittels den die Artikel gefiltert werden. Wenn der Wert leer ist, werden alle Artikel angezeigt.',
+ 'posts_per_page' => 'Artikel pro Seite',
+ 'posts_per_page_validation' => 'Ungültiger "Artikel pro Seiten" Wert',
+ 'posts_no_posts' => 'Keine Artikel Nachricht',
+ 'posts_no_posts_description' => 'Nachricht welche dargestellt wird wenn keine Artikel vorhanden sind. Dieser Wert wird von der Standard-Komponente verwendet.',
+ 'posts_order' => 'Artikel Sortierung',
+ 'posts_order_description' => 'Attribute nach welchem Artikel sortiert werden.',
+ 'posts_category' => 'Kategorien-Seite',
+ 'posts_category_description' => 'Name der Kategorien-Seiten-Datei für "Veröffentlicht in" Kategorien-Links. Dieser Wert von der Standard-Komponente verwendet.',
+ 'posts_post' => 'Artikel Seite',
+ 'posts_post_description' => 'Name der Artikel-Seiten-Datei für die "Erfahre mehr" Links. Dieser Wert für von der Standard-Komponente verwendet.',
+ 'posts_except_post' => 'Artikel ausschließen',
+ 'posts_except_post_description' => 'Gebe direkt die ID/URL oder eine Variable mit der Artikel-ID/URL an um diesen Artikel auszuschließen. Dieser Wert für von der Standard-Komponente verwendet.',
+ 'posts_except_categories' => 'Kategorien ausschließen',
+ 'posts_except_categories_description' => 'Gebe eine kommagetrennte Liste von Kategorie-Slugs oder eine Variable mit einer solchen Liste an um deren Artikel auszuschließen. Die Dieser Wert für von der Standard-Komponente verwendet.',
+ 'rssfeed_blog' => 'Blog Seite',
+ 'rssfeed_blog_description' => 'Name der Artikel-Seiten-Datei für die Links. Dieser Wert für von der Standard-Komponente verwendet.',
+ 'rssfeed_title' => 'RSS-Feed',
+ 'rssfeed_description' => 'Erstellt einen RSS-Feed mit Artikeln aus dem Blog.',
+ 'group_links' => 'Links',
+ 'group_exceptions' => 'Ausnahmen'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/en/lang.php b/plugins/rainlab/blog/lang/en/lang.php
new file mode 100644
index 0000000..06e7e47
--- /dev/null
+++ b/plugins/rainlab/blog/lang/en/lang.php
@@ -0,0 +1,169 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'A robust blogging platform.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Manage Blog Posts',
+ 'posts' => 'Posts',
+ 'create_post' => 'Blog post',
+ 'categories' => 'Categories',
+ 'create_category' => 'Blog category',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Manage the blog posts',
+ 'access_categories' => 'Manage the blog categories',
+ 'access_other_posts' => 'Manage other users blog posts',
+ 'access_import_export' => 'Allowed to import and export posts',
+ 'access_publish' => 'Allowed to publish posts',
+ 'manage_settings' => 'Manage blog settings',
+ 'delete_confirm' => 'Are you sure?',
+ 'chart_published' => 'Published',
+ 'chart_drafts' => 'Drafts',
+ 'chart_total' => 'Total',
+ 'settings_description' => 'Manage blog settings',
+ 'show_all_posts_label' => 'Show All Posts to Backend Users',
+ 'show_all_posts_comment' => 'Display both published and unpublished posts on the frontend to backend users',
+ 'use_legacy_editor_label' => 'Use the Legacy Markdown Editor',
+ 'use_legacy_editor_comment' => 'Enable the older version of the markdown editor when using October CMS v2 and above',
+ 'tab_general' => 'General',
+ 'preview' => 'Preview'
+ ],
+ 'posts' => [
+ 'list_title' => 'Manage the blog posts',
+ 'filter_category' => 'Category',
+ 'filter_published' => 'Published',
+ 'filter_date' => 'Date',
+ 'new_post' => 'New Post',
+ 'export_post' => 'Export Posts',
+ 'import_post' => 'Import Posts'
+ ],
+ 'post' => [
+ 'title' => 'Title',
+ 'title_placeholder' => 'New post title',
+ 'content' => 'Content',
+ 'content_html' => 'HTML Content',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'new-post-slug',
+ 'categories' => 'Categories',
+ 'author_email' => 'Author Email',
+ 'created' => 'Created',
+ 'created_date' => 'Created date',
+ 'updated' => 'Updated',
+ 'updated_date' => 'Updated date',
+ 'published' => 'Published',
+ 'published_by' => 'Published by',
+ 'current_user' => 'Current user',
+ 'published_date' => 'Published date',
+ 'published_validation' => 'Please specify the published date',
+ 'tab_edit' => 'Edit',
+ 'tab_categories' => 'Categories',
+ 'categories_comment' => 'Select categories the blog post belongs to',
+ 'categories_placeholder' => 'There are no categories, you should create one first!',
+ 'tab_manage' => 'Manage',
+ 'published_on' => 'Published on',
+ 'excerpt' => 'Excerpt',
+ 'summary' => 'Summary',
+ 'featured_images' => 'Featured Images',
+ 'delete_confirm' => 'Delete this post?',
+ 'delete_success' => 'Successfully deleted those posts.',
+ 'close_confirm' => 'The post is not saved.',
+ 'return_to_posts' => 'Return to posts list',
+ 'posted_byline' => 'Posted in :categories on :date.',
+ 'posted_byline_no_categories' => 'Posted on :date.',
+ 'date_format' => 'M d, Y',
+ ],
+ 'categories' => [
+ 'list_title' => 'Manage the blog categories',
+ 'new_category' => 'New Category',
+ 'uncategorized' => 'Uncategorized'
+ ],
+ 'category' => [
+ 'name' => 'Name',
+ 'name_placeholder' => 'New category name',
+ 'description' => 'Description',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'new-category-slug',
+ 'posts' => 'Posts',
+ 'delete_confirm' => 'Delete this category?',
+ 'delete_success' => 'Successfully deleted those categories.',
+ 'return_to_categories' => 'Return to the blog category list',
+ 'reorder' => 'Reorder Categories'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog category',
+ 'all_blog_categories' => 'All blog categories',
+ 'blog_post' => 'Blog post',
+ 'all_blog_posts' => 'All blog posts',
+ 'category_blog_posts' => 'Blog category posts'
+ ],
+ 'settings' => [
+ 'category_title' => 'Category List',
+ 'category_description' => 'Displays a list of blog categories on the page.',
+ 'category_slug' => 'Category slug',
+ 'category_slug_description' => "Look up the blog category using the supplied slug value. This property is used by the default component partial for marking the currently active category.",
+ 'category_display_empty' => 'Display empty categories',
+ 'category_display_empty_description' => 'Show categories that do not have any posts.',
+ 'category_page' => 'Category page',
+ 'category_page_description' => 'Name of the category page file for the category links. This property is used by the default component partial.',
+ 'post_title' => 'Post',
+ 'post_description' => 'Displays a blog post on the page.',
+ 'post_slug' => 'Post slug',
+ 'post_slug_description' => "Look up the blog post using the supplied slug value.",
+ 'post_category' => 'Category page',
+ 'post_category_description' => 'Name of the category page file for the category links. This property is used by the default component partial.',
+ 'posts_title' => 'Post List',
+ 'posts_description' => 'Displays a list of latest blog posts on the page.',
+ 'posts_pagination' => 'Page number',
+ 'posts_pagination_description' => 'This value is used to determine what page the user is on.',
+ 'posts_filter' => 'Category filter',
+ 'posts_filter_description' => 'Enter a category slug or URL parameter to filter the posts by. Leave empty to show all posts.',
+ 'posts_per_page' => 'Posts per page',
+ 'posts_per_page_validation' => 'Invalid format of the posts per page value',
+ 'posts_no_posts' => 'No posts message',
+ 'posts_no_posts_description' => 'Message to display in the blog post list in case if there are no posts. This property is used by the default component partial.',
+ 'posts_no_posts_default' => 'No posts found',
+ 'posts_order' => 'Post order',
+ 'posts_order_description' => 'Attribute on which the posts should be ordered',
+ 'posts_category' => 'Category page',
+ 'posts_category_description' => 'Name of the category page file for the "Posted into" category links. This property is used by the default component partial.',
+ 'posts_post' => 'Post page',
+ 'posts_post_description' => 'Name of the blog post page file for the "Learn more" links. This property is used by the default component partial.',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to exclude. You may use a comma-separated list to specify multiple posts.',
+ 'posts_except_post_validation' => 'Post exceptions must be a single slug or ID, or a comma-separated list of slugs and IDs',
+ 'posts_except_categories' => 'Except categories',
+ 'posts_except_categories_description' => 'Enter a comma-separated list of category slugs or variable with such a list of categories you want to exclude',
+ 'posts_except_categories_validation' => 'Category exceptions must be a single category slug, or a comma-separated list of slugs',
+ 'rssfeed_blog' => 'Blog page',
+ 'rssfeed_blog_description' => 'Name of the main blog page file for generating links. This property is used by the default component partial.',
+ 'rssfeed_title' => 'RSS Feed',
+ 'rssfeed_description' => 'Generates an RSS feed containing posts from the blog.',
+ 'group_links' => 'Links',
+ 'group_exceptions' => 'Exceptions'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Title (ascending)',
+ 'title_desc' => 'Title (descending)',
+ 'created_asc' => 'Created (ascending)',
+ 'created_desc' => 'Created (descending)',
+ 'updated_asc' => 'Updated (ascending)',
+ 'updated_desc' => 'Updated (descending)',
+ 'published_asc' => 'Published (ascending)',
+ 'published_desc' => 'Published (descending)',
+ 'random' => 'Random'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Update existing posts',
+ 'update_existing_comment' => 'Check this box to update posts that have exactly the same ID, title or slug.',
+ 'auto_create_categories_label' => 'Create categories specified in the import file',
+ 'auto_create_categories_comment' => 'You should match the Categories column to use this feature, otherwise select the default categories to use from the items below.',
+ 'categories_label' => 'Categories',
+ 'categories_comment' => 'Select the categories that imported posts will belong to (optional).',
+ 'default_author_label' => 'Default post author (optional)',
+ 'default_author_comment' => 'The import will try to use an existing author if you match the Author Email column, otherwise the author specified above is used.',
+ 'default_author_placeholder' => '-- select author --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/es/lang.php b/plugins/rainlab/blog/lang/es/lang.php
new file mode 100644
index 0000000..d34e8e4
--- /dev/null
+++ b/plugins/rainlab/blog/lang/es/lang.php
@@ -0,0 +1,166 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Una plataforma robusta de blogging.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Administrar Publicaciones',
+ 'posts' => 'Publicaciones',
+ 'create_post' => 'Crear publicación',
+ 'categories' => 'Categorías',
+ 'create_category' => 'Categoría',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Administrar las publicaciones',
+ 'access_categories' => 'Administrar las categorías',
+ 'access_other_posts' => 'Administrar publicaciones de otros usuarios',
+ 'access_import_export' => 'Autorizado para importar y exportar publicaciones',
+ 'access_publish' => 'Autorizado para publicar publicaciones',
+ 'manage_settings' => 'Administrar configuración del blog',
+ 'delete_confirm' => '¿Está seguro?',
+ 'chart_published' => 'Publicado',
+ 'chart_drafts' => 'Borradores',
+ 'chart_total' => 'Total',
+ 'settings_description' => 'Administrar configuración del blog',
+ 'show_all_posts_label' => 'Mostrar todas las publicaciones a los usuarios de backend',
+ 'show_all_posts_comment' => 'Mostrar las publicaciones publicados y los borradores a los usuarios de backend',
+ 'tab_general' => 'General'
+ ],
+ 'posts' => [
+ 'list_title' => 'Administrar publicaciones',
+ 'filter_category' => 'Categoría',
+ 'filter_published' => 'Publicado',
+ 'filter_date' => 'Fecha',
+ 'new_post' => 'Nueva publicación',
+ 'export_post' => 'Exportar publicaciones',
+ 'import_post' => 'Importar publicaciones'
+ ],
+ 'post' => [
+ 'title' => 'Título',
+ 'title_placeholder' => 'Título de la publicación',
+ 'content' => 'Contenido',
+ 'content_html' => 'Contenido HTML',
+ 'slug' => 'Identificador',
+ 'slug_placeholder' => 'nueva-publicacion',
+ 'categories' => 'Categorías',
+ 'author_email' => 'Email del Autor',
+ 'created' => 'Creado',
+ 'created_date' => 'Fecha de Creación',
+ 'updated' => 'Actualizado',
+ 'updated_date' => 'Fecha de Actualización',
+ 'published' => 'Publicado',
+ 'published_by' => 'Publicado por',
+ 'current_user' => 'Usuario actual',
+ 'published_date' => 'Fecha de publicación',
+ 'published_validation' => 'Por favor, especifique la fecha de publicación',
+ 'tab_edit' => 'Editar',
+ 'tab_categories' => 'Categorías',
+ 'categories_comment' => 'Seleccione las categorías para la publicación',
+ 'categories_placeholder' => 'No hay categorías, ¡crea una primero!',
+ 'tab_manage' => 'Administrar',
+ 'published_on' => 'Publicado el',
+ 'excerpt' => 'Resumen',
+ 'summary' => 'Resumen',
+ 'featured_images' => 'Imágenes Destacadas',
+ 'delete_confirm' => '¿Borrar la publicación?',
+ 'delete_success' => 'Publicación borrada correctamente',
+ 'close_confirm' => 'La publicación no está guardada.',
+ 'return_to_posts' => 'Volver a la lista de publicaciones',
+ 'posted_byline' => 'Publicado en :categories el :date.',
+ 'posted_byline_no_categories' => 'Publicado el :date.',
+ 'date_format' => 'd de M de Y',
+ ],
+ 'categories' => [
+ 'list_title' => 'Administrar las categorías',
+ 'new_category' => 'Nueva categoría',
+ 'uncategorized' => 'Sin Categoría'
+ ],
+ 'category' => [
+ 'name' => 'Nombre',
+ 'name_placeholder' => 'Nombre de la categoría',
+ 'description' => 'Descripción',
+ 'slug' => 'Identificador',
+ 'slug_placeholder' => 'nueva-categoría',
+ 'posts' => 'Publicaciones',
+ 'delete_confirm' => '¿Borrar esta categoría?',
+ 'delete_success' => 'Categorías borradas correctamente.',
+ 'return_to_categories' => 'Volver a la lista de categorías',
+ 'reorder' => 'Re-ordenar Categorías'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Categoría del blog',
+ 'all_blog_categories' => 'Todas las categorías del blog',
+ 'blog_post' => 'Publicación del blog',
+ 'all_blog_posts' => 'Todas las publicaciones del blog',
+ 'category_blog_posts' => 'Publicaciones del blog por categorías'
+ ],
+ 'settings' => [
+ 'category_title' => 'Lista de Categorías',
+ 'category_description' => 'Muestra en la página una lista de las categorías.',
+ 'category_slug' => 'Identificador de la categoría',
+ 'category_slug_description' => "Localiza una categoría utilizando el identificador proporcionado. Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente para marcar la categoría activa.",
+ 'category_display_empty' => 'Mostrar categorías vacías',
+ 'category_display_empty_description' => 'Mostrar categorías que no tienen ninguna publicación.',
+ 'category_page' => 'Página de categorías',
+ 'category_page_description' => 'Nombre del archivo de página utilizado para los enlaces de categorías. Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'post_title' => 'Publicación',
+ 'post_description' => 'Muestra una publicación en la página.',
+ 'post_slug' => 'Identificador de la publicación',
+ 'post_slug_description' => "Se buscará la publicación utilizando el valor del identificador proporcionado.",
+ 'post_category' => 'Página de categoría',
+ 'post_category_description' => 'Nombre del archivo de página utilizado para los enlaces de categorías. Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'posts_title' => 'Lista de publicaciones',
+ 'posts_description' => 'Muestra una lista de las últimas publicaciones en la página.',
+ 'posts_pagination' => 'Número de página',
+ 'posts_pagination_description' => 'Este valor se utiliza para determinar en que página se encuentra el usuario.',
+ 'posts_filter' => 'Filtro de categoría',
+ 'posts_filter_description' => 'Ingrese un identificador de categoría o parámetro URL. Se utilizará para filtrar las publicaciones. Deje el campo vacío para mostrar todas las publicaciones.',
+ 'posts_per_page' => 'Publicaciones por página',
+ 'posts_per_page_validation' => 'Formato inválido para el valor de publicaciones por página',
+ 'posts_no_posts' => 'Mensaje cuando no hay publicaciones',
+ 'posts_no_posts_description' => 'Mensaje que se mostrará en la lista de publicaciones del blog cuando no haya ningúno. Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'posts_no_posts_default' => 'No se encontraron publicaciones.',
+ 'posts_order' => 'Ordenar publicaciones por',
+ 'posts_order_description' => 'Atributo mediante el cual se deberán ordenar las publicaciones',
+ 'posts_category' => 'Página de Categoría',
+ 'posts_category_description' => 'Nombre del archivo de página utilizado para los enlaces de categoría "Publicado en". Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'posts_post' => 'Página de las publicaciones',
+ 'posts_post_description' => 'Nombre del archivo de página utilizado para los enlaces "Saber más". Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'posts_except_post' => 'Exceptuar publicación',
+ 'posts_except_post_description' => 'Ingrese una ID/URL o variable que contenga una ID/URL de la publicación que se quiera excluir',
+ 'posts_except_post_validation' => 'La publicación a excluir debe ser una ID/URL, o una lista separada por comas de IDs/URLs',
+ 'posts_except_categories' => 'Excluir categorías',
+ 'posts_except_categories_description' => 'Introduce una lista separada por comas de IDs/URLs de categorías con las categorías a excluir.',
+ 'posts_except_categories_validation' => 'Las categorías excluidas deben ser una URL de categoría o una lista separada por comas',
+ 'rssfeed_blog' => 'Página del blog',
+ 'rssfeed_blog_description' => 'Nombre del archivo de página principal para generación de enlaces. Esta propiedad es utilizada dentro del parcial que viene por defecto en el componente.',
+ 'rssfeed_title' => 'RSS Feed',
+ 'rssfeed_description' => 'Genera un feed de RSS con las publicaciones del blog.',
+ 'group_links' => 'Enlaces',
+ 'group_exceptions' => 'Excepciones'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Título (ascendiente)',
+ 'title_desc' => 'Título (descendiente)',
+ 'created_asc' => 'Creado (ascendiente)',
+ 'created_desc' => 'Creado (descendiente)',
+ 'updated_asc' => 'Editado (ascendiente)',
+ 'updated_desc' => 'Editado (descendiente)',
+ 'published_asc' => 'Publicado (ascendiente)',
+ 'published_desc' => 'Publicado (descendiente)',
+ 'random' => 'Aleatorio'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Editar publicaciones existentes',
+ 'update_existing_comment' => 'Selecciona este check para actualizar las publicaciones con exactamente la misma ID, título o URL.',
+ 'auto_create_categories_label' => 'Crear categorías especificadas en el archivo a importar',
+ 'auto_create_categories_comment' => 'Debes hacer coincidir la columna Categoría para usar esta funcionalidad, sino selecciona la categoría por defecto para para usar para los elementos de abajo.',
+ 'categories_label' => 'Categorías',
+ 'categories_comment' => 'Selecciona las categorías a las que pertenecerán las publicaciones importadas (opcional).',
+ 'default_author_label' => 'Autor de publicación por defecto (opcional)',
+ 'default_author_comment' => 'La importación intentará usar un autor existente si coicide con la columna "Author Email", sino se usará el autor especificado arriba.',
+ 'default_author_placeholder' => '-- Selecciona Autor/a --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/fa/lang.php b/plugins/rainlab/blog/lang/fa/lang.php
new file mode 100644
index 0000000..0413451
--- /dev/null
+++ b/plugins/rainlab/blog/lang/fa/lang.php
@@ -0,0 +1,109 @@
+ [
+ 'name' => 'وبلاگ',
+ 'description' => 'پلتفرم قوی برای وبلاگ نویسی'
+ ],
+ 'blog' => [
+ 'menu_label' => 'وبلاگ',
+ 'menu_description' => 'مدیریت پست های ارسالی',
+ 'posts' => 'پست ها',
+ 'create_post' => 'ایجاد پست جدید',
+ 'categories' => 'دسته بندی ها',
+ 'create_category' => 'ایجاد دسته بندی جدید',
+ 'tab' => 'وبلاگ',
+ 'access_posts' => 'مدیریت پست های ارسالی',
+ 'access_categories' => 'مدیریت دسته بندی های وبلاگ',
+ 'access_other_posts' => 'مدیریت پست های ارسالی سایر کاربران',
+ 'access_import_export' => 'توانایی واردکردن و خارج کردن پستها',
+ 'delete_confirm' => 'آیا اطمینان دارید؟',
+ 'chart_published' => 'منتشر شده',
+ 'chart_drafts' => 'پیش نویس',
+ 'chart_total' => 'مجموع'
+ ],
+ 'posts' => [
+ 'list_title' => 'مدیریت پست های ارسالی',
+ 'filter_category' => 'دسته بندی',
+ 'filter_published' => 'مخفی کردن منتشر شده ها',
+ 'new_post' => 'پست جدید'
+ ],
+ 'post' => [
+ 'title' => 'عنوان',
+ 'title_placeholder' => 'عنوان پست جدید',
+ 'content' => 'محتوی',
+ 'content_html' => 'محتوی HTML',
+ 'slug' => 'آدرس',
+ 'slug_placeholder' => 'آدرس-پست-جدید',
+ 'categories' => 'دسته بندی ها',
+ 'author_email' => 'پست الکترونیکی نویسنده',
+ 'created' => 'ایجاد شده در',
+ 'created_date' => 'تاریخ ایجاد',
+ 'updated' => 'به روزرسانی شده در',
+ 'updated_date' => 'تاریخ به روزرسانی',
+ 'published' => 'منتشر شده',
+ 'published_date' => 'تاریخ انتشار',
+ 'published_validation' => 'لطفا تاریخ انتشار را وارد نمایید',
+ 'tab_edit' => 'ویرایش',
+ 'tab_categories' => 'دسته بندی ها',
+ 'categories_comment' => 'دسته بندی هایی را که پست به آنها تعلق دارد را انتخاب نمایید',
+ 'categories_placeholder' => 'دسته بندی ای وجود ندارد. ابتدا یک دسته بندی ایجاد نمایید!',
+ 'tab_manage' => 'مدیریت',
+ 'published_on' => 'منتشر شده در',
+ 'excerpt' => 'خلاصه',
+ 'summary' => 'چکیده',
+ 'featured_images' => 'تصاویر شاخص',
+ 'delete_confirm' => 'آیا از حذف این پست اطمینان دارید؟',
+ 'close_confirm' => 'پست ذخیره نشده است',
+ 'return_to_posts' => 'بازگشت به لیست پست ها'
+ ],
+ 'categories' => [
+ 'list_title' => 'مدیریت دسته بندی های وبلاگ',
+ 'new_category' => 'دسته بندی جدید',
+ 'uncategorized' => 'بدون دسته بندی'
+ ],
+ 'category' => [
+ 'name' => 'نام',
+ 'name_placeholder' => 'نام دسته بندی جدید',
+ 'slug' => 'آدرس',
+ 'slug_placeholder' => 'آدرس-جدید-دسته-بندی',
+ 'posts' => 'پست ها',
+ 'delete_confirm' => 'آیا از حذف این دسته بندی اطمینان دارید؟',
+ 'return_to_categories' => 'بازگشت به لیست دسته بندی های وبلاگ',
+ 'reorder' => 'مرتب سازی دسته بندی ها'
+ ],
+ 'settings' => [
+ 'category_title' => 'لیست دسته بندی',
+ 'category_description' => 'نمایش لیست دسته بندی های وبلاگ در صفحه',
+ 'category_slug' => 'آدرس دسته بندی',
+ 'category_slug_description' => "دسته بندی وبلاگ توسط آدرس وارد شده جستجو می شود. این عمل توسط ابزار دسته بندی برای برجسته ساختن دسته بندی در حال نمایش استفاده می شود.",
+ 'category_display_empty' => 'نمایش دسته بندی های خالی',
+ 'category_display_empty_description' => 'نمایش دسته بندی هایی که هیچ ارسالی در آنها وجود ندارد.',
+ 'category_page' => 'صفحه دسته بندی',
+ 'category_page_description' => 'نام صفحه ای که لیست دسته بندی ها در آن نمایش داده می شوند. این گزینه به طور پیشفرض توسط ابزار مورد استفاده قرار میگیرد.',
+ 'post_title' => 'پست',
+ 'post_description' => 'نمایش پست در صفحه',
+ 'post_slug' => 'آدرس پست',
+ 'post_slug_description' => "پست توسط آدرس وارد شده جستجو میشود.",
+ 'post_category' => 'صفحه دسته بندی',
+ 'post_category_description' => 'نام صفحه ای که لیست دسته بندی ها در آن نمایش داده می شوند. این گزینه به طور پیشفرض توسط ابزار مورد استفاده قرار میگیرد.',
+ 'posts_title' => 'لیست پست ها',
+ 'posts_description' => 'نمایش لیستی از پستهایی که اخیرا ارسال شده اند در صفحه.',
+ 'posts_pagination' => 'شماره صفحه',
+ 'posts_pagination_description' => 'این مقدار جهت تشخیص صفحه ای که کاربر در آن قرار دارد مورد استفاده قرار میگیرد.',
+ 'posts_filter' => 'فیلتر دسته بندی',
+ 'posts_filter_description' => 'آدرس دسته بندی ای را که میخواهید پست های آن نمایش داده شوند را وارد نمایید. اگر میخواهید همه پست ها نمایش داده شوند این مقدار را خالی رها کنید.',
+ 'posts_per_page' => 'تعداد پست ها در هر صفحه',
+ 'posts_per_page_validation' => 'مقدار ورودی تعداد پست ها در هر صفحه نامعتبر است.',
+ 'posts_no_posts' => 'پیغام پستی وجود ندارد',
+ 'posts_no_posts_description' => 'این پیغام در صورتی که پستی جهت نمایش وجود نداشته باشد، نمایش داده می شود.',
+ 'posts_order' => 'ترتیب پست ها',
+ 'posts_order_decription' => 'مشخصه ترتیب نمایش پست ها در صفحه',
+ 'posts_category' => 'صفحه دسته بندی',
+ 'posts_category_description' => 'نام صفحه دسته بندی برای نمایش پستهای مربوط به آن.',
+ 'posts_post' => 'صفحه پست',
+ 'posts_post_description' => 'نام صفحه مربوط به نمایش کامل پست ها جهت لینک ادامه مطلب',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/fi/lang.php b/plugins/rainlab/blog/lang/fi/lang.php
new file mode 100644
index 0000000..284c921
--- /dev/null
+++ b/plugins/rainlab/blog/lang/fi/lang.php
@@ -0,0 +1,163 @@
+ [
+ 'name' => 'Blogi',
+ 'description' => 'Vankka bloggausalusta.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blogi',
+ 'menu_description' => 'Hallitse blogipostauksia',
+ 'posts' => 'Postaukset',
+ 'create_post' => 'Blogipostaus',
+ 'categories' => 'Categories',
+ 'create_category' => 'Blogikategoria',
+ 'tab' => 'Blogi',
+ 'access_posts' => 'Hallitse postauksia',
+ 'access_categories' => 'Hallitse kategorioita',
+ 'access_other_posts' => 'Hallitse muiden käyttäjien postauksia',
+ 'access_import_export' => 'Saa tuoda ja viedä postauksia',
+ 'access_publish' => 'Saa julkaista postauksia',
+ 'manage_settings' => 'Manage blog settings',
+ 'delete_confirm' => 'Olteko varma?',
+ 'chart_published' => 'Julkaistu',
+ 'chart_drafts' => 'Luonnokset',
+ 'chart_total' => 'Yhteensä',
+ 'settings_description' => 'Hallinnoi blogin asetuksia',
+ 'show_all_posts_label' => 'Näytä kaikki postaukset ylläpitäjille',
+ 'show_all_posts_comment' => 'Näytä molemmat sekä julkaistut että julkaisemattomat postaukset ylläpitäjille',
+ 'tab_general' => 'Yleiset'
+ ],
+ 'posts' => [
+ 'list_title' => 'Hallitse blogipostauksia',
+ 'filter_category' => 'Kategoria',
+ 'filter_published' => 'Julkaistu',
+ 'filter_date' => 'Päivämäärä',
+ 'new_post' => 'Uusi postaus',
+ 'export_post' => 'Vie postaukset',
+ 'import_post' => 'Tuo postauksia'
+ ],
+ 'post' => [
+ 'title' => 'Otsikko',
+ 'title_placeholder' => 'Uuden postauksen otsikko',
+ 'content' => 'Sisältö',
+ 'content_html' => 'HTML Sisältö',
+ 'slug' => 'Slugi',
+ 'slug_placeholder' => 'uuden-postaukse-slugi',
+ 'categories' => 'Kategoriat',
+ 'author_email' => 'Tekijän sähköposti',
+ 'created' => 'Luotu',
+ 'created_date' => 'Luomispäivämäärä',
+ 'updated' => 'Muokattu',
+ 'updated_date' => 'Muokkauspäivämäärä',
+ 'published' => 'Julkaistu',
+ 'published_by' => 'Published by',
+ 'current_user' => 'Current user',
+ 'published_date' => 'Julkaisupäivämäärä',
+ 'published_validation' => 'Määrittele julkaisupäivämäärä',
+ 'tab_edit' => 'Muokkaa',
+ 'tab_categories' => 'Kategoriat',
+ 'categories_comment' => 'Valitse kategoriat joihin postaus kuuluu',
+ 'categories_placeholder' => 'Kategorioita ei ole, sinun pitäisi luoda ensimmäinen ensin!',
+ 'tab_manage' => 'Hallitse',
+ 'published_on' => 'Julkaistu',
+ 'excerpt' => 'Poiminto',
+ 'summary' => 'Yhteenveto',
+ 'featured_images' => 'Esittelykuvat',
+ 'delete_confirm' => 'Poista tämä postaus?',
+ 'delete_success' => 'Postaukset poistettu onnistuneesti.',
+ 'close_confirm' => 'Tämä postaus ei ole tallennettu.',
+ 'return_to_posts' => 'Palaa postauslistaan'
+ ],
+ 'categories' => [
+ 'list_title' => 'Hallitse blogikategorioita',
+ 'new_category' => 'Uusi kategoria',
+ 'uncategorized' => 'Luokittelematon'
+ ],
+ 'category' => [
+ 'name' => 'Nimi',
+ 'name_placeholder' => 'Uuden kategorian nimi',
+ 'description' => 'Kuvaus',
+ 'slug' => 'Slugi',
+ 'slug_placeholder' => 'uuden-kategorian-slugi',
+ 'posts' => 'Julkaisuja',
+ 'delete_confirm' => 'Poista tämä kategoria?',
+ 'delete_success' => 'Kategoriat poistettu onnistuneesti.',
+ 'return_to_categories' => 'Palaa blogikategorialistaan',
+ 'reorder' => 'Järjestä kategoriat uudelleen'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blogikategoria',
+ 'all_blog_categories' => 'Kaikki blogikategoriat',
+ 'blog_post' => 'Blogipostaukset',
+ 'all_blog_posts' => 'Kaikki blogipostaukset',
+ 'category_blog_posts' => 'Blogin kategorian postaukset'
+ ],
+ 'settings' => [
+ 'category_title' => 'Kategorialista',
+ 'category_description' => 'Näyttää listan blogikategorioista sivulla.',
+ 'category_slug' => 'Kategorian slugi',
+ 'category_slug_description' => 'Etsii blogikategorian käyttämällä annettua slugi-arvoa. Komponentti käyttää tätä merkitsemään aktiivisen kategorian.',
+ 'category_display_empty' => 'Näytä tyhjät kategoriat',
+ 'category_display_empty_description' => 'Näytä kategoriat joilla ei ole yhtään postauksia.',
+ 'category_page' => 'Kategoriasivu',
+ 'category_page_description' => 'Kategorialistaussivun tiedostonimi. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'post_title' => 'Postaus',
+ 'post_description' => 'Näyttää blogipostauksen sivulla.',
+ 'post_slug' => 'Postauksen slugi',
+ 'post_slug_description' => 'Etsii blogipostauksen käyttämällä annettua slugi-arvoa.',
+ 'post_category' => 'Kategoriasivu',
+ 'post_category_description' => 'Kategorialistaussivun tiedostonimi. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'posts_title' => 'Lista postauksista',
+ 'posts_description' => 'Näyttää listan uusimmista blogipostauksista sivulla.',
+ 'posts_pagination' => 'Sivunumero',
+ 'posts_pagination_description' => 'Tätä arvoa käytetään määrittämään millä sivulla käyttäjä on.',
+ 'posts_filter' => 'Kategoriasuodatin',
+ 'posts_filter_description' => 'Lisää kategorian slugi tai URL parametri, jolla suodattaa postauksia. Jätä tyhjäksi näyttääksesi kaikki postaukset.',
+ 'posts_per_page' => 'Postauksia per sivu',
+ 'posts_per_page_validation' => 'Postauksia per sivu -kohta sisältää kelvottoman arvon',
+ 'posts_no_posts' => 'Ei julkaisuja -viesti',
+ 'posts_no_posts_description' => 'Viesti, joka näytetään silloin kun postauksia ei ole. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'posts_no_posts_default' => 'Ei postauksia',
+ 'posts_order' => 'Postauksien järjestys',
+ 'posts_order_description' => 'Attribuutti, jonka mukaan postaukset tulisi järjestää',
+ 'posts_category' => 'Kategoriasivu',
+ 'posts_category_description' => 'Kategoriasivun tiedosto "Julkaistu kohteeseen" kategorialinkkejä varten. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'posts_post' => 'Postaussivu',
+ 'posts_post_description' => 'Blogisivun tiedostonimi "Lue lisää" linkkejä varten. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'posts_except_post' => 'Poissulje postauksia',
+ 'posts_except_post_description' => 'Lisää postauksen ID/URL tai muuttuja, jonka haluat poissulkea',
+ 'posts_except_post_validation' => 'Postaukset poikkeukset täytyy olla yksittäinen slugi tai ID, pilkulla erotettu slugi-lista ja ID:t',
+ 'posts_except_categories' => 'Poikkeavat kategoriat',
+ 'posts_except_categories_description' => 'Lisää pilkulla erotettu listaus kategoria slugeista tai listaus kategorioista jotka haluat jättää ulkopuolelle',
+ 'posts_except_categories_validation' => 'Poikkeavat kategoriat ovat oltava yksittäinen kategoria slugi tai pilkulla erotettu listaus slugeista',
+ 'rssfeed_blog' => 'Blogisivu',
+ 'rssfeed_blog_description' => 'Blogisivun tiedostonimi linkkien generointia varten. Oletuskomponenttiosa käyttää tätä ominaisuutta.',
+ 'rssfeed_title' => 'RSS syöte',
+ 'rssfeed_description' => 'Generoi RSS syötteen sisältäen postaukset blogista.',
+ 'group_links' => 'Linkit',
+ 'group_exceptions' => 'Poikkeukset'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Otsikko (ascending)',
+ 'title_desc' => 'Otsikko (descending)',
+ 'created_asc' => 'Luotu (ascending)',
+ 'created_desc' => 'Luotu (descending)',
+ 'updated_asc' => 'Päivitetty (ascending)',
+ 'updated_desc' => 'Päivitetty (descending)',
+ 'published_asc' => 'Julkaistu (ascending)',
+ 'published_desc' => 'Julkaistu (descending)',
+ 'random' => 'Satunnainen'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Päivitä olemassa olevat postaukset',
+ 'update_existing_comment' => 'Valitse tämä laatikko päivittääksesi postaukset, joissa on täsmälleen sama ID, otsikko tai slugi.',
+ 'auto_create_categories_label' => 'Luo tuotavassa tiedostossa määritellyt kategoriat.',
+ 'auto_create_categories_comment' => 'Sinun tulisi yhdistää Kategoriat-sarake käyttääksesi tätä toiminnallisuutta. Muussa tapauksessa valitse oletuskategoria alapuolelta.',
+ 'categories_label' => 'Kategoriat',
+ 'categories_comment' => 'Valitse kategoriat, joihin tuotavat postaukset liitetään (option).',
+ 'default_author_label' => 'Oletuskirjoittaja (optio)',
+ 'default_author_comment' => 'Tuonti yrittää käyttää Kirjoittaja tiedon sähköpostia yhdistäessään kirjoittajaa. Muussa tapauksessa käytetään ylempänä määriteltyä.',
+ 'default_author_placeholder' => '-- valitse kirjoittaja --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/fr/lang.php b/plugins/rainlab/blog/lang/fr/lang.php
new file mode 100644
index 0000000..c2b8600
--- /dev/null
+++ b/plugins/rainlab/blog/lang/fr/lang.php
@@ -0,0 +1,166 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Une plateforme de blog robuste.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Gestion d’articles de blog',
+ 'posts' => 'Articles',
+ 'create_post' => 'article de blog',
+ 'categories' => 'Catégories',
+ 'create_category' => 'catégorie d’articles',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Gérer les articles',
+ 'access_categories' => 'Gérer les catégories',
+ 'access_other_posts' => 'Gérer les articles d’autres utilisateurs',
+ 'access_import_export' => 'Autorisé à importer et exporter des articles',
+ 'access_publish' => 'Autorisé à publier des articles',
+ 'manage_settings' => 'Gérer les paramètres du blog',
+ 'delete_confirm' => 'Confirmez-vous la suppression des articles sélectionnés ?',
+ 'chart_published' => 'Publié',
+ 'chart_drafts' => 'Brouillons',
+ 'chart_total' => 'Total',
+ 'settings_description' => 'Gérer les paramètres du blog',
+ 'show_all_posts_label' => "Afficher tous les messages aux utilisateurs du panneaux d'administration",
+ 'show_all_posts_comment' => 'Afficher autant les publications publiées et non publiées sur le site web pour les utilisateurs principaux',
+ 'tab_general' => 'Général'
+ ],
+ 'posts' => [
+ 'list_title' => 'Gérer les articles du blog',
+ 'filter_category' => 'Catégorie',
+ 'filter_published' => 'Masquer la publication',
+ 'filter_date' => 'Date',
+ 'new_post' => 'Nouvel article',
+ 'export_post' => 'Exporter les articles',
+ 'import_post' => 'Importer des articles'
+ ],
+ 'post' => [
+ 'title' => 'Titre',
+ 'title_placeholder' => 'Titre du nouvel article',
+ 'content' => 'Contenu',
+ 'content_html' => 'Contenu HTML',
+ 'slug' => 'Adresse',
+ 'slug_placeholder' => 'adresse-du-nouvel-article',
+ 'categories' => 'Catégories',
+ 'author_email' => 'Email de l’auteur',
+ 'created' => 'Créé',
+ 'created_date' => 'Date de création',
+ 'updated' => 'Mis a jour',
+ 'updated_date' => 'Date de mise à jour',
+ 'published' => 'Publié',
+ 'published_by' => 'Publié par',
+ 'current_user' => 'Utilisateur actuel',
+ 'published_date' => 'Date de publication',
+ 'published_validation' => 'Veuillez préciser la date de publication',
+ 'tab_edit' => 'Rédaction',
+ 'tab_categories' => 'Catégories',
+ 'categories_comment' => 'Sélectionnez les catégories auxquelles l’article est lié',
+ 'categories_placeholder' => 'Il n’existe pas encore de catégorie, mais vous pouvez en créer une !',
+ 'tab_manage' => 'Configuration',
+ 'published_on' => 'Publié le',
+ 'excerpt' => 'Extrait',
+ 'summary' => 'Résumé',
+ 'featured_images' => 'Image de promotion',
+ 'delete_confirm' => 'Confirmez-vous la suppression de cet article ?',
+ 'delete_success' => 'Ces articles ont été supprimés avec succès.',
+ 'close_confirm' => 'L’article n’est pas enregistré.',
+ 'return_to_posts' => 'Retour à la liste des articles',
+ 'posted_byline' => 'Posté dans :categories le :date.',
+ 'posted_byline_no_categories' => 'Posté le :date.',
+ 'date_format' => 'd M Y'
+ ],
+ 'categories' => [
+ 'list_title' => 'Gérer les catégories',
+ 'new_category' => 'Nouvelle catégorie',
+ 'uncategorized' => 'Non catégorisé'
+ ],
+ 'category' => [
+ 'name' => 'Nom',
+ 'name_placeholder' => 'Nom de la nouvelle catégorie',
+ 'description' => 'Description',
+ 'slug' => 'Adresse URL',
+ 'slug_placeholder' => 'adresse-de-la-nouvelle-catégorie',
+ 'posts' => 'Articles',
+ 'delete_confirm' => 'Confirmez-vous la suppression de cette catégorie ?',
+ 'delete_success' => 'Ces catégories ont été supprimés avec succès.',
+ 'return_to_categories' => 'Retour à la liste des catégories',
+ 'reorder' => 'Réorganiser les catégories'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Catégories du blog',
+ 'all_blog_categories' => 'Toutes les catégories du blog',
+ 'blog_post' => 'Articles du blog',
+ 'all_blog_posts' => 'Tous les articles du blog',
+ 'category_blog_posts' => "Articles d'une catégorie du blog"
+ ],
+ 'settings' => [
+ 'category_title' => 'Liste des catégories',
+ 'category_description' => 'Afficher une liste des catégories sur la page.',
+ 'category_slug' => 'Adresse URL de la catégorie',
+ 'category_slug_description' => 'Adresse URL d’accès à la catégorie. Cette propriété est utilisée par le partial par défaut du composant pour marquer la catégorie courante comme active.',
+ 'category_display_empty' => 'Afficher les catégories vides.',
+ 'category_display_empty_description' => 'Afficher les catégories qui ne sont liés à aucun article.',
+ 'category_page' => 'Page des catégories',
+ 'category_page_description' => 'Nom de la page des catégories pour les liens de catégories. Cette propriété est utilisée par le partial par défaut du composant.',
+ 'post_title' => 'Article',
+ 'post_description' => 'Affiche un article de blog sur la page.',
+ 'post_slug' => 'Adresse URL de l’article',
+ 'post_slug_description' => 'Adresse URL d’accès à l’article.',
+ 'post_category' => 'Page des catégories',
+ 'post_category_description' => 'Nom de la page des catégories pour les liens de catégories. Cette propriété est utilisée par le partial par défaut du composant.',
+ 'posts_title' => 'Liste d’articles',
+ 'posts_description' => 'Affiche une liste des derniers articles de blog sur la page.',
+ 'posts_pagination' => 'Numéro de page',
+ 'posts_pagination_description' => 'Cette valeur est utilisée pour déterminer à quelle page l’utilisateur se trouve.',
+ 'posts_filter' => 'Filtre des catégories',
+ 'posts_filter_description' => 'Entrez une adresse de catégorie ou un paramètre d’URL pour filter les articles. Laissez vide pour afficher tous les articles.',
+ 'posts_per_page' => 'Articles par page',
+ 'posts_per_page_validation' => 'Format du nombre d’articles par page incorrect',
+ 'posts_no_posts' => 'Message en l’absence d’articles',
+ 'posts_no_posts_description' => 'Message à afficher dans la liste d’articles lorsqu’il n’y a aucun article. Cette propriété est utilisée par le partial par défaut du composant.',
+ 'posts_no_posts_default' => 'Aucun article trouvé',
+ 'posts_order' => 'Ordre des articles',
+ 'posts_order_description' => 'Attribut selon lequel les articles seront ordonnés',
+ 'posts_category' => 'Page de catégorie',
+ 'posts_category_description' => 'Nom du fichier de la page de catégorie pour les liens de catégorie "Publié dans". Cette propriété est utilisée par le composant par défaut du modèle partiel.',
+ 'posts_post' => "Page de l'article",
+ 'posts_post_description' => 'Nom du fichier de la page de l\'article du blog pour les liens "En savoir plus". Cette propriété est utilisée par le composant par défaut du modèle partiel.',
+ 'posts_except_post' => 'Article exempté',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ 'posts_category' => 'Page des catégories',
+ 'posts_category_description' => 'Nom de la page des catégories pour les liens de catégories "Publié dans". Cette propriété est utilisée par le partial par défaut du composant.',
+ 'posts_post' => 'Page d’article',
+ 'posts_post_description' => 'Nom de la page d’articles pour les liens "En savoir plus". Cette propriété est utilisée par le partial par défaut du composant.',
+ 'rssfeed_blog' => 'Page du blog',
+ 'rssfeed_blog_description' => 'Nom de la page principale du blog pour générer les liens. Cette propriété est utilisé par le composant dans le partial.',
+ 'rssfeed_title' => 'Flux RSS',
+ 'rssfeed_description' => 'Génère un Flux RSS contenant les articles du blog.',
+ 'group_links' => 'Liens',
+ 'group_exceptions' => 'Exceptions'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Titre (ascendant)',
+ 'title_desc' => 'Titre (descendant)',
+ 'created_asc' => 'Création le (ascendant)',
+ 'created_desc' => 'Création (descendant)',
+ 'updated_asc' => 'Mise à jour (ascendant)',
+ 'updated_desc' => 'Mise à jour (descendant)',
+ 'published_asc' => 'Publication (ascendant)',
+ 'published_desc' => 'Publication (descendant)',
+ 'random' => 'Aléatoire'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Mettre à jour les articles existants',
+ 'update_existing_comment' => 'Cochez cette case pour mettre à jour les articles qui ont exactement le même identifiant, titre ou slug.',
+ 'auto_create_categories_label' => "Créer les catégories spécifiées dans le fichier d'importation",
+ 'auto_create_categories_comment' => 'Vous devez faire correspondre la colonne Catégories pour utiliser cette fonctionnalité. Sinon, sélectionnez les catégories par défaut à utiliser parmi les éléments ci-dessous.',
+ 'categories_label' => 'Catégories',
+ 'categories_comment' => 'Sélectionnez les catégories auxquelles appartiendront les articles importées (facultatif).',
+ 'default_author_label' => 'Auteur par défaut (facultatif)',
+ 'default_author_comment' => "L'importation tentera d'utiliser un auteur existant si vous correspondez la colonne Email à l'auteur, sinon l'auteur spécifié ci-dessus sera utilisé.",
+ 'default_author_placeholder' => "-- sélectionnez l'auteur --"
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/hu/lang.php b/plugins/rainlab/blog/lang/hu/lang.php
new file mode 100644
index 0000000..e996f60
--- /dev/null
+++ b/plugins/rainlab/blog/lang/hu/lang.php
@@ -0,0 +1,166 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Teljeskörű blog alkalmazás.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Blog bejegyzések kezelése',
+ 'posts' => 'Bejegyzések',
+ 'create_post' => 'blog bejegyzés',
+ 'categories' => 'Kategóriák',
+ 'create_category' => 'blog kategória',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Blog bejegyzések kezelése',
+ 'access_categories' => 'Blog kategóriák kezelése',
+ 'access_other_posts' => 'Más felhasználók bejegyzéseinek kezelése',
+ 'access_import_export' => 'Bejegyzések importálása és exportálása',
+ 'access_publish' => 'Blog bejegyzések közzététele',
+ 'manage_settings' => 'Blog beállítások kezelése',
+ 'delete_confirm' => 'Törölni akarja a kijelölt bejegyzéseket?',
+ 'chart_published' => 'Közzétéve',
+ 'chart_drafts' => 'Piszkozatok',
+ 'chart_total' => 'Összesen',
+ 'settings_description' => 'Beállítási lehetőségek.',
+ 'show_all_posts_label' => 'Az összes bejegyzés mutatása az adminisztrátorok számára',
+ 'show_all_posts_comment' => 'A közzétett és a még nem publikált bejegyzések is egyaránt meg fognak jelenni az oldal szerkesztőinek.',
+ 'tab_general' => 'Általános'
+ ],
+ 'posts' => [
+ 'list_title' => 'Blog bejegyzések',
+ 'filter_category' => 'Kategória',
+ 'filter_published' => 'Közzétéve',
+ 'filter_date' => 'Létrehozva',
+ 'new_post' => 'Új bejegyzés',
+ 'export_post' => 'Exportálás',
+ 'import_post' => 'Importálás'
+ ],
+ 'post' => [
+ 'title' => 'Cím',
+ 'title_placeholder' => 'Új bejegyzés címe',
+ 'content' => 'Szöveges tartalom',
+ 'content_html' => 'HTML tartalom',
+ 'slug' => 'Keresőbarát cím',
+ 'slug_placeholder' => 'uj-bejegyzes-cime',
+ 'categories' => 'Kategóriák',
+ 'author_email' => 'Szerző e-mail címe',
+ 'created' => 'Létrehozva',
+ 'created_date' => 'Létrehozás dátuma',
+ 'updated' => 'Módosítva',
+ 'updated_date' => 'Módosítás dátuma',
+ 'published' => 'Közzétéve',
+ 'published_by' => 'Szerző:',
+ 'current_user' => 'Felhasználó',
+ 'published_date' => 'Közzététel dátuma',
+ 'published_validation' => 'Adja meg a közzététel dátumát',
+ 'tab_edit' => 'Szerkesztés',
+ 'tab_categories' => 'Kategóriák',
+ 'categories_comment' => 'Jelölje be azokat a kategóriákat, melyekbe be akarja sorolni a bejegyzést',
+ 'categories_placeholder' => 'Nincsenek kategóriák, előbb létre kell hoznia egyet!',
+ 'tab_manage' => 'Kezelés',
+ 'published_on' => 'Közzététel dátuma',
+ 'excerpt' => 'Kivonat',
+ 'summary' => 'Összegzés',
+ 'featured_images' => 'Kiemelt képek',
+ 'delete_confirm' => 'Valóban törölni akarja ezt a bejegyzést?',
+ 'delete_success' => 'Sikeresen törölve lettek a bejegyzések.',
+ 'close_confirm' => 'A bejegyzés nem került mentésre.',
+ 'return_to_posts' => 'Vissza a bejegyzésekhez',
+ 'posted_byline' => 'Publikálva: :date, itt: :categories',
+ 'posted_byline_no_categories' => 'Publikálva: :date.',
+ 'date_format' => 'Y.M.d.',
+ ],
+ 'categories' => [
+ 'list_title' => 'Blog kategóriák',
+ 'new_category' => 'Új kategória',
+ 'uncategorized' => 'Nincs kategorizálva'
+ ],
+ 'category' => [
+ 'name' => 'Név',
+ 'name_placeholder' => 'Új kategória neve',
+ 'description' => 'Leírás',
+ 'slug' => 'Keresőbarát cím',
+ 'slug_placeholder' => 'uj-kategoria-neve',
+ 'posts' => 'Bejegyzések',
+ 'delete_confirm' => 'Valóban törölni akarja ezt a kategóriát?',
+ 'delete_success' => 'Sikeresen törölve lettek a kategóriák.',
+ 'return_to_categories' => 'Vissza a kategóriákhoz',
+ 'reorder' => 'Kategóriák sorrendje'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog kategória',
+ 'all_blog_categories' => 'Összes blog kategória',
+ 'blog_post' => 'Blog bejegyzés',
+ 'all_blog_posts' => 'Összes blog bejegyzés',
+ 'category_blog_posts' => 'Blog kategória bejegyzések'
+ ],
+ 'settings' => [
+ 'category_title' => 'Blog kategória lista',
+ 'category_description' => 'A blog kategóriákat listázza ki a lapon.',
+ 'category_slug' => 'Cím paraméter neve',
+ 'category_slug_description' => 'A webcím útvonal paramétere a jelenlegi kategória keresőbarát címe alapján való kereséséhez. Az alapértelmezett komponensrész ezt a tulajdonságot használja a jelenleg aktív kategória megjelöléséhez.',
+ 'category_display_empty' => 'Üres kategóriák kijelzése',
+ 'category_display_empty_description' => 'Azon kategóriák megjelenítése, melyekben nincs egy bejegyzés sem.',
+ 'category_page' => 'Kategória lap',
+ 'category_page_description' => 'A kategória hivatkozások kategória lap fájljának neve. Az alapértelmezett komponensrész használja ezt a tulajdonságot.',
+ 'post_title' => 'Blog bejegyzés',
+ 'post_description' => 'Egy blog bejegyzést jelez ki a lapon.',
+ 'post_slug' => 'Cím paraméter neve',
+ 'post_slug_description' => 'A webcím útvonal paramétere a bejegyzés keresőbarát címe alapján való kereséséhez.',
+ 'post_category' => 'Kategória lap',
+ 'post_category_description' => 'A kategória hivatkozások kategória lap fájljának neve. Az alapértelmezett komponensrész használja ezt a tulajdonságot.',
+ 'posts_title' => 'Blog bejegyzések',
+ 'posts_description' => 'A közzétett blog bejegyzések listázása a honlapon.',
+ 'posts_pagination' => 'Lapozósáv paraméter neve',
+ 'posts_pagination_description' => 'A lapozósáv lapjai által használt, várt paraméter neve.',
+ 'posts_filter' => 'Kategória szűrő',
+ 'posts_filter_description' => 'Adja meg egy kategória keresőbarát címét vagy webcím paraméterét a bejegyzések szűréséhez. Hagyja üresen az összes bejegyzés megjelenítéséhez.',
+ 'posts_per_page' => 'Bejegyzések laponként',
+ 'posts_per_page_validation' => 'A laponkénti bejegyzések értéke érvénytelen formátumú',
+ 'posts_no_posts' => 'Üzenet ha nincs bejegyzés',
+ 'posts_no_posts_description' => 'A blog bejegyzés listában kijelezendő üzenet abban az esetben, ha nincsenek bejegyzések. Az alapértelmezett komponensrész használja ezt a tulajdonságot.',
+ 'posts_no_posts_default' => 'Nem található bejegyzés',
+ 'posts_order' => 'Bejegyzések sorrendje',
+ 'posts_order_description' => 'Jellemző, ami alapján rendezni kell a bejegyzéseket',
+ 'posts_category' => 'Kategória lap',
+ 'posts_category_description' => 'A "Kategória" kategória hivatkozások kategória lap fájljának neve. Az alapértelmezett komponensrész használja ezt a tulajdonságot.',
+ 'posts_post' => 'Bejegyzéslap',
+ 'posts_post_description' => 'A "Tovább olvasom" hivatkozások blog bejegyzéslap fájljának neve. Az alapértelmezett komponensrész használja ezt a tulajdonságot.',
+ 'posts_except_post' => 'Bejegyzés kizárása',
+ 'posts_except_post_description' => 'Adja meg annak a bejegyzésnek az azonosítóját vagy webcímét, amit nem akar megjeleníteni a listázáskor.',
+ 'posts_except_post_validation' => 'A kivételnek webcímnek, illetve azonosítónak, vagy pedig ezeknek a vesszővel elválasztott felsorolásának kell lennie.',
+ 'posts_except_categories' => 'Kategória kizárása',
+ 'posts_except_categories_description' => 'Adja meg azoknak a kategóriáknak a webcímét vesszővel elválasztva, amiket nem akar megjeleníteni a listázáskor.',
+ 'posts_except_categories_validation' => 'A kivételnek webcímnek, vagy pedig ezeknek a vesszővel elválasztott felsorolásának kell lennie.',
+ 'rssfeed_blog' => 'Blog oldal',
+ 'rssfeed_blog_description' => 'Annak a lapnak a neve, ahol listázódnak a blog bejegyzések. Ezt a beállítást használja alapértelmezetten a blog komponens is.',
+ 'rssfeed_title' => 'RSS hírfolyam',
+ 'rssfeed_description' => 'A bloghoz tartozó RSS hírfolyam generálása.',
+ 'group_links' => 'Hivatkozások',
+ 'group_exceptions' => 'Kivételek'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Név (növekvő)',
+ 'title_desc' => 'Név (csökkenő)',
+ 'created_asc' => 'Létrehozva (növekvő)',
+ 'created_desc' => 'Létrehozva (csökkenő)',
+ 'updated_asc' => 'Frissítve (növekvő)',
+ 'updated_desc' => 'Frissítve (csökkenő)',
+ 'published_asc' => 'Publikálva (növekvő)',
+ 'published_desc' => 'Publikálva (csökkenő)',
+ 'random' => 'Véletlenszerű'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Meglévő bejegyzések frissítése',
+ 'update_existing_comment' => 'Két bejegyzés akkor számít ugyanannak, ha megegyezik az azonosító számuk, a címük vagy a webcímük.',
+ 'auto_create_categories_label' => 'Az import fájlban megadott kategóriák létrehozása',
+ 'auto_create_categories_comment' => 'A funkció használatához meg kell felelnie a Kategóriák oszlopnak, különben az alábbi elemekből válassza ki az alapértelmezett kategóriákat.',
+ 'categories_label' => 'Kategóriák',
+ 'categories_comment' => 'Válassza ki azokat a kategóriákat, amelyekhez az importált bejegyzések tartoznak (nem kötelező).',
+ 'default_author_label' => 'Alapértelmezett szerző (nem kötelező)',
+ 'default_author_comment' => 'A rendszer megpróbál egy meglévő felhasználót társítani a bejegyzéshez az Email oszlop alapján. Amennyiben ez nem sikerül, az itt megadott szerzőt fogja alapul venni.',
+ 'default_author_placeholder' => '-- válasszon felhasználót --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/it/lang.php b/plugins/rainlab/blog/lang/it/lang.php
new file mode 100644
index 0000000..0eade81
--- /dev/null
+++ b/plugins/rainlab/blog/lang/it/lang.php
@@ -0,0 +1,107 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Una solida piattaforma di blogging.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Gestisci i post',
+ 'posts' => 'Post',
+ 'create_post' => 'post del blog',
+ 'categories' => 'Categorie',
+ 'create_category' => 'categorie del blog',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Gestisci i post',
+ 'access_categories' => 'Gestisci le categorie',
+ 'access_other_posts' => 'Gestisci i post di altri utenti',
+ 'access_import_export' => 'Permesso ad importare ed esportare i post',
+ 'delete_confirm' => 'Sei sicuro?',
+ 'chart_published' => 'Pubblicato',
+ 'chart_drafts' => 'Bozze',
+ 'chart_total' => 'Totale'
+ ],
+ 'posts' => [
+ 'list_title' => 'Gestisci i post',
+ 'category' => 'Categoria',
+ 'hide_published' => 'Nascondi pubblicati',
+ 'new_post' => 'Nuovo post'
+ ],
+ 'post' => [
+ 'title' => 'Titolo',
+ 'title_placeholder' => 'Titolo del nuovo post',
+ 'content' => 'Contenuto',
+ 'content_html' => 'Contenuto HTML',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'slug-del-nuovo-post',
+ 'categories' => 'Categorie',
+ 'author_email' => 'Email dell\'autore',
+ 'created' => 'Creato',
+ 'created_date' => 'Data di creazione',
+ 'updated' => 'Aggiornato',
+ 'updated_date' => 'Data di aggiornamento',
+ 'published' => 'Pubblicato',
+ 'published_date' => 'Data di pubblicazione',
+ 'published_validation' => 'Per favore fornisci la data di pubblicazione',
+ 'tab_edit' => 'Modifica',
+ 'tab_categories' => 'Categorie',
+ 'categories_comment' => 'Seleziona le categorie a cui appartiene il post',
+ 'categories_placeholder' => 'Non ci sono categorie, per iniziare dovresti crearne una!',
+ 'tab_manage' => 'Gestisci',
+ 'published_on' => 'Pubblicato il',
+ 'excerpt' => 'Estratto',
+ 'summary' => 'Riassunto',
+ 'featured_images' => 'Immagini in evidenza',
+ 'delete_confirm' => 'Vuoi veramente cancellare questo post?',
+ 'close_confirm' => 'Questo post non è salvato.',
+ 'return_to_posts' => 'Ritorna all\'elenco dei post'
+ ],
+ 'categories' => [
+ 'list_title' => 'Gestisci le categorie del blog',
+ 'new_category' => 'Nuova categoria',
+ 'uncategorized' => 'Non categorizzato'
+ ],
+ 'category' => [
+ 'name' => 'Nome',
+ 'name_placeholder' => 'Nome della nuova categoria',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'slug-nuova-categoria',
+ 'posts' => 'Post',
+ 'delete_confirm' => 'Vuoi veramente cancellare questa categoria?',
+ 'return_to_categories' => 'Ritorna all\'elenco delle categorie del blog',
+ 'reorder' => 'Riordino Categorie'
+ ],
+ 'settings' => [
+ 'category_title' => 'Elenco Categorie',
+ 'category_description' => 'Mostra un\'elenco delle categorie del blog sulla pagina.',
+ 'category_slug' => 'Slug categoria',
+ 'category_slug_description' => "Cerca la categoria del blog usando lo slug fornito. Questa proprietà è usata dal componente parziale di default per segnare la categoria attualmente usata.",
+ 'category_display_empty' => 'Mostra categorie vuote',
+ 'category_display_empty_description' => 'Mostra categorie che non hanno alcun post.',
+ 'category_page' => 'Pagina delle categorie',
+ 'category_page_description' => 'Nome del file della pagina delle categorie contenente i link delle categorie. Questa proprietà è usata dal componente parziale di default.',
+ 'post_title' => 'Post',
+ 'post_description' => 'Mostra un post sulla pagina.',
+ 'post_slug' => 'Slug del post',
+ 'post_slug_description' => "Cerca il post con lo slug fornito.",
+ 'post_category' => 'Pagina delle categorie',
+ 'post_category_description' => 'Nome del file della pagina delle categorie contenente i link delle categorie. Questa proprietà è usata dal componente parziale di default.',
+ 'posts_title' => 'Elenco dei post',
+ 'posts_description' => 'Mostra un\'elenco degli ultimi post sulla pagina.',
+ 'posts_pagination' => 'Numero di pagina',
+ 'posts_pagination_description' => 'Questo valore è usato per determinare su quale pagina è l\'utente.',
+ 'posts_filter' => 'Filtro delle categorie',
+ 'posts_filter_description' => 'Inserisci lo slug di una categoria o un parametro dell\'URL con il quale filtrare i post. Lascia vuoto per mostrare tutti i post.',
+ 'posts_per_page' => 'Post per pagina',
+ 'posts_per_page_validation' => 'Il valore di post per pagina ha un formato non valido ',
+ 'posts_no_posts' => 'Messaggio per l\'assenza di post',
+ 'posts_no_posts_description' => 'Messaggio da mostrare nell\'elenco dei post in caso non ce ne siano. Questa proprietà è usata dal componente parziale di default.',
+ 'posts_order' => 'Ordine dei post',
+ 'posts_order_description' => 'Attributo sul quale i post dovrebbero esser ordinati',
+ 'posts_category' => 'Pagina delle categorie',
+ 'posts_category_description' => 'Nome del file per la pagina delle categorie per i link "Postato in" alle categorie. Questa proprietà è usata dal componente parziale di default.',
+ 'posts_post' => 'Pagina del post',
+ 'posts_post_description' => 'Nome del file per la pagina del post per i link "Scopri di più". Questa proprietà è usata dal componente parziale di default.'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/ja/lang.php b/plugins/rainlab/blog/lang/ja/lang.php
new file mode 100644
index 0000000..be6d0b4
--- /dev/null
+++ b/plugins/rainlab/blog/lang/ja/lang.php
@@ -0,0 +1,100 @@
+ [
+ 'name' => 'ブログ',
+ 'description' => 'ロバストなブログプラットフォームです。'
+ ],
+ 'blog' => [
+ 'menu_label' => 'ブログ',
+ 'menu_description' => 'ブログの投稿管理',
+ 'posts' => '投稿',
+ 'create_post' => '投稿の追加',
+ 'categories' => 'カテゴリ',
+ 'create_category' => 'カテゴリの追加',
+ 'tab' => 'ブログ',
+ 'access_posts' => '投稿の管理',
+ 'access_categories' => 'カテゴリの管理',
+ 'access_other_posts' => '他ユーザーの投稿の管理',
+ 'delete_confirm' => '削除していいですか?',
+ 'chart_published' => '公開済み',
+ 'chart_drafts' => '下書き',
+ 'chart_total' => '合計'
+ ],
+ 'posts' => [
+ 'list_title' => '投稿の管理',
+ 'filter_category' => 'カテゴリ',
+ 'filter_published' => '下書きのみ',
+ 'new_post' => '投稿を追加'
+ ],
+ 'post' => [
+ 'title' => 'タイトル',
+ 'title_placeholder' => 'タイトルを入力してください',
+ 'slug' => 'スラッグ',
+ 'slug_placeholder' => 'new-post-slug−123',
+ 'categories' => 'カテゴリ',
+ 'created' => '作成日',
+ 'updated' => '更新日',
+ 'published' => '公開する',
+ 'published_validation' => '投稿の公開日を指定してください。',
+ 'tab_edit' => '編集',
+ 'tab_categories' => 'カテゴリ',
+ 'categories_comment' => '投稿を関連付けるカテゴリを選択してください。(複数選択可)',
+ 'categories_placeholder' => 'まだカテゴリがありません。先に作成してください。',
+ 'tab_manage' => '管理',
+ 'published_on' => '公開日',
+ 'excerpt' => '投稿の抜粋',
+ 'featured_images' => 'アイキャッチ画像',
+ 'delete_confirm' => '削除していいですか?',
+ 'close_confirm' => '投稿は保存されていません。',
+ 'return_to_posts' => '投稿一覧に戻る'
+ ],
+ 'categories' => [
+ 'list_title' => 'カテゴリ管理',
+ 'new_category' => 'カテゴリの追加',
+ 'uncategorized' => '未分類'
+ ],
+ 'category' => [
+ 'name' => '名前',
+ 'name_placeholder' => 'カテゴリ名をつけてください',
+ 'slug' => 'スラッグ',
+ 'slug_placeholder' => 'new-category-slug-123',
+ 'posts' => '投稿数',
+ 'delete_confirm' => '削除していいですか?',
+ 'return_to_categories' => 'カテゴリ一覧に戻る'
+ ],
+ 'settings' => [
+ 'category_title' => 'カテゴリリスト',
+ 'category_description' => 'ページ内にカテゴリリストを表示します。',
+ 'category_slug' => 'カテゴリスラッグ',
+ 'category_slug_description' => "表示するカテゴリのスラッグを指定します。この項目はコンポーネントのデフォルトパーシャルで使用されます。",
+ 'category_display_empty' => '空のカテゴリの表示',
+ 'category_display_empty_description' => 'この項目がチェックされている場合、投稿が0件のカテゴリもリストに表示します。',
+ 'category_page' => 'カテゴリページ',
+ 'category_page_description' => 'カテゴリページへのリンクを生成するために、カテゴリページのファイル名を指定します。この項目はコンポーネントのデフォルトパーシャルで使用されます。',
+ 'post_title' => '投稿',
+ 'post_description' => 'ページ内に投稿を表示します。',
+ 'post_slug' => '投稿スラッグ',
+ 'post_slug_description' => "表示する投稿のスラッグを指定します。特定の投稿のスラッグか、URLパラメータ(:slug)を指定できます。",
+ 'post_category' => 'カテゴリページ',
+ 'post_category_description' => 'カテゴリリンクを生成するために、カテゴリページのファイル名を指定します。拡張子(.htm)は省いてください。この項目はコンポーネントのデフォルトパーシャルで使用されます。',
+ 'posts_title' => '投稿リスト',
+ 'posts_description' => 'ページ内に新しい投稿のリストを表示します。',
+ 'posts_pagination' => 'ページ番号',
+ 'posts_pagination_description' => 'ページ番号を指定します。URLパラメータ(:page)を指定できます。',
+ 'posts_filter' => 'カテゴリフィルタ',
+ 'posts_filter_description' => '投稿リストのフィルタを指定します。カテゴリのスラッグかURLパラメータ(:slug)を指定できます。空の場合、すべての投稿が表示されます。',
+ 'posts_per_page' => '1ページに表示する投稿数を指定します。',
+ 'posts_per_page_validation' => '1ページに表示する投稿数の形式が正しくありません。',
+ 'posts_no_posts' => '0件時メッセージ',
+ 'posts_no_posts_description' => 'この投稿リストが0件の場合に表示するメッセージを指定します。この項目はコンポーネントのデフォルトパーシャルで使用されます。',
+ 'posts_order' => '並び順',
+ 'posts_order_description' => '投稿リスト内の並び順を指定します。',
+ 'posts_category' => 'カテゴリページ',
+ 'posts_category_description' => 'カテゴリリンクを生成するために、カテゴリページのファイル名を指定します。この項目はコンポーネントのデフォルトパーシャルで使用されます。',
+ 'posts_post' => '投稿ページ',
+ 'posts_post_description' => '"Learn more"リンクを生成するため、投稿ページのファイル名を指定します。拡張子(.htm)は省いてください。この項目はコンポーネントのデフォルトパーシャルで使用されます。',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/nb-no/lang.php b/plugins/rainlab/blog/lang/nb-no/lang.php
new file mode 100644
index 0000000..d033632
--- /dev/null
+++ b/plugins/rainlab/blog/lang/nb-no/lang.php
@@ -0,0 +1,99 @@
+ [
+ 'name' => 'Blogg',
+ 'description' => 'En robust bloggeplattform.',
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blogg',
+ 'menu_description' => 'Administrer blogginnlegg',
+ 'posts' => 'Innlegg',
+ 'create_post' => 'innlegg',
+ 'categories' => 'Kategorier',
+ 'create_category' => 'kategori',
+ 'access_posts' => 'Administrer blogginnleggene',
+ 'access_categories' => 'Administrer bloggkategorier',
+ 'access_other_posts' => 'Administrere andre brukere sine blogginnlegg',
+ 'delete_confirm' => 'Er du sikker?',
+ 'chart_published' => 'Publisert',
+ 'chart_drafts' => 'Utkast',
+ 'chart_total' => 'Totalt',
+ ],
+ 'posts' => [
+ 'list_title' => 'Administrer blogginnlegg',
+ 'filter_category' => 'Kategori',
+ 'filter_published' => 'Skjul publiserte',
+ 'new_post' => 'Nytt innlegg',
+ ],
+ 'post' => [
+ 'title' => 'Tittel',
+ 'title_placeholder' => 'Innleggets tittel',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'innleggets-tittel',
+ 'categories' => 'Kategorier',
+ 'created' => 'Opprettet',
+ 'updated' => 'Oppdatert',
+ 'published' => 'Publisert',
+ 'published_validation' => 'Velg en dato når innlegget skal publiseres',
+ 'tab_edit' => 'Endre',
+ 'tab_categories' => 'Kategorier',
+ 'categories_comment' => 'Velg hvilke kategorier innlegget tilhører',
+ 'categories_placeholder' => 'Det finnes ingen kategorier! Vennligst opprett en først.',
+ 'tab_manage' => 'Egenskaper',
+ 'published_on' => 'Publiseringsdato',
+ 'excerpt' => 'Utdrag',
+ 'featured_images' => 'Utvalgte bilder',
+ 'delete_confirm' => 'Vil du virkelig slette dette innlegget?',
+ 'close_confirm' => 'Innlegget er ikke lagret.',
+ 'return_to_posts' => 'Tilbake til innleggsliste',
+ ],
+ 'categories' => [
+ 'list_title' => 'Administrer bloggkategorier',
+ 'new_category' => 'Ny kategori',
+ 'uncategorized' => 'Uten kategori',
+ ],
+ 'category' => [
+ 'name' => 'Navn',
+ 'name_placeholder' => 'Kategoriens navn',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'kategoriens-navn',
+ 'posts' => 'Innlegg',
+ 'delete_confirm' => 'Vil du virkelig slette denne kategorien?',
+ 'return_to_categories' => 'Tilbake til kategorilisten',
+ ],
+ 'settings' => [
+ 'category_title' => 'Category List',
+ 'category_description' => 'Displays a list of blog categories on the page.',
+ 'category_slug' => 'Category slug',
+ 'category_slug_description' => "Look up the blog category using the supplied slug value. This property is used by the default component partial for marking the currently active category.",
+ 'category_display_empty' => 'Display empty categories',
+ 'category_display_empty_description' => 'Show categories that do not have any posts.',
+ 'category_page' => 'Category page',
+ 'category_page_description' => 'Name of the category page file for the category links. This property is used by the default component partial.',
+ 'post_title' => 'Post',
+ 'post_description' => 'Displays a blog post on the page.',
+ 'post_slug' => 'Post slug',
+ 'post_slug_description' => "Look up the blog post using the supplied slug value.",
+ 'post_category' => 'Category page',
+ 'post_category_description' => 'Name of the category page file for the category links. This property is used by the default component partial.',
+ 'posts_title' => 'Post List',
+ 'posts_description' => 'Displays a list of latest blog posts on the page.',
+ 'posts_pagination' => 'Page number',
+ 'posts_pagination_description' => 'This value is used to determine what page the user is on.',
+ 'posts_filter' => 'Category filter',
+ 'posts_filter_description' => 'Enter a category slug or URL parameter to filter the posts by. Leave empty to show all posts.',
+ 'posts_per_page' => 'Posts per page',
+ 'posts_per_page_validation' => 'Invalid format of the posts per page value',
+ 'posts_no_posts' => 'No posts message',
+ 'posts_no_posts_description' => 'Message to display in the blog post list in case if there are no posts. This property is used by the default component partial.',
+ 'posts_order' => 'Post order',
+ 'posts_order_description' => 'Attribute on which the posts should be ordered',
+ 'posts_category' => 'Category page',
+ 'posts_category_description' => 'Name of the category page file for the "Posted into" category links. This property is used by the default component partial.',
+ 'posts_post' => 'Post page',
+ 'posts_post_description' => 'Name of the blog post page file for the "Learn more" links. This property is used by the default component partial.',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ ],
+];
diff --git a/plugins/rainlab/blog/lang/nl/lang.php b/plugins/rainlab/blog/lang/nl/lang.php
new file mode 100644
index 0000000..1d5b782
--- /dev/null
+++ b/plugins/rainlab/blog/lang/nl/lang.php
@@ -0,0 +1,113 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'A robust blogging platform.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Beheer blog artikelen',
+ 'posts' => 'Artikelen',
+ 'create_post' => 'Artikel',
+ 'categories' => 'Categorieën',
+ 'create_category' => 'blog categorie',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Blog artikelen beheren',
+ 'access_categories' => 'Blog categorieën beheren',
+ 'access_other_posts' => 'Beheren van blog artikelen van gebruikers',
+ 'access_import_export' => 'Toegang tot importeren en exporteren van artikelen',
+ 'delete_confirm' => 'Weet je het zeker?',
+ 'chart_published' => 'Gepubliceerd',
+ 'chart_drafts' => 'Concepten',
+ 'chart_total' => 'Totaal'
+ ],
+ 'posts' => [
+ 'list_title' => 'Beheren van blog artikelen',
+ 'filter_category' => 'Categorie',
+ 'filter_published' => 'Verberg gepubliceerd',
+ 'new_post' => 'Nieuw artikel'
+ ],
+ 'post' => [
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Titel van artikel',
+ 'content' => 'Inhoud',
+ 'content_html' => 'HTML Inhoud',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'nieuw-artikel-slug',
+ 'categories' => 'Categorieën',
+ 'author_email' => 'E-mail auteur',
+ 'created' => 'Aangemaakt',
+ 'created_date' => 'Aangemaakt op',
+ 'updated' => 'Bijgewerkt',
+ 'updated_date' => 'Bijgewerkt op',
+ 'published' => 'Gepubliceerd',
+ 'published_by' => 'Gepubliceerd door',
+ 'current_user' => 'Huidige gebruiker',
+ 'published_date' => 'Gepubliceerd op',
+ 'published_validation' => 'Graag een publicatie datum opgeven',
+ 'tab_edit' => 'Bewerken',
+ 'tab_categories' => 'Categorieën',
+ 'categories_comment' => 'Selecteer een categorie waarbij het artikel hoort',
+ 'categories_placeholder' => 'Er zijn geen categorieën, maak eerst een categorie aan!',
+ 'tab_manage' => 'Beheer',
+ 'published_on' => 'Gepubliceerd op',
+ 'excerpt' => 'Samenvatting',
+ 'summary' => 'Samenvatting',
+ 'featured_images' => 'Uitgelichte afbeelding',
+ 'delete_confirm' => 'Weet je zeker dat je dit artikel wilt verwijderen?',
+ 'close_confirm' => 'Artikel is nog niet opgeslagen.',
+ 'return_to_posts' => 'Terug naar artikel overzicht',
+ 'posted_byline' => 'Gepubliceerd in :categories op :date.',
+ 'posted_byline_no_categories' => 'Gepubliceerd op :date.',
+ 'date_format' => 'd, M, Y',
+ ],
+ 'categories' => [
+ 'list_title' => 'Beheer blog categorieën',
+ 'new_category' => 'Nieuwe categorie',
+ 'uncategorized' => 'Ongecategoriseerd'
+ ],
+ 'category' => [
+ 'name' => 'Naam',
+ 'name_placeholder' => 'Naam categorie',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'nieuw-categorie-slug',
+ 'posts' => 'Artikelen',
+ 'delete_confirm' => 'Weet je zeker dat je deze categorie wilt verwijderen?',
+ 'return_to_categories' => 'Terug naar categorie overzicht'
+ ],
+ 'settings' => [
+ 'category_title' => 'Categorie overzicht',
+ 'category_description' => 'Geeft een lijst weer van alle categorieën op de pagina.',
+ 'category_slug' => 'Categorie slug',
+ 'category_slug_description' => 'Haal blog categorie op a.h.v. de opgegeven slug. Deze waarde wordt standaard gebruikt voor de partial om de actieve categorie te markeren.',
+ 'category_display_empty' => 'Geef lege categorieën weer',
+ 'category_display_empty_description' => 'Geef categorieën weer die geen artikelen hebben.',
+ 'category_page' => 'Categorie pagina',
+ 'category_page_description' => 'Naam van categorie pagina bestand voor de categorie links. Deze waarde wordt standaard gebruikt door de partial.',
+ 'post_title' => 'Artikel',
+ 'post_description' => 'Geef een artikel weer op de pagina.',
+ 'post_slug' => 'Artikel slug',
+ 'post_slug_description' => 'Haal een artikel op a.h.v. de opgegeven slug.',
+ 'post_category' => 'Categorie pagina',
+ 'post_category_description' => 'Naam van categorie pagina bestand voor de categorie links. Deze waarde wordt standaard gebruikt door de partial.',
+ 'posts_title' => 'Artikel overzicht',
+ 'posts_description' => 'Geeft een lijst van de nieuwste artikelen weer op de pagina.',
+ 'posts_pagination' => 'Pagina nummer',
+ 'posts_pagination_description' => 'Deze waarde wordt gebruikt om te kijken op welke pagina de gebruiker is.',
+ 'posts_filter' => 'Categorie filter',
+ 'posts_filter_description' => 'Geef een categorie slug of URL param om de artikelen hier op te kunnen filteren. Leeg laten als alle artikelen getoond moeten worden.',
+ 'posts_per_page' => 'Artikelen per pagina',
+ 'posts_per_page_validation' => 'Ongeldig formaat voor het aantal artikelen per pagina',
+ 'posts_no_posts' => 'Geen artikelen melding',
+ 'posts_no_posts_description' => 'Deze tekst wordt getoond als er geen artikelen zijn. Deze waarde wordt standaard gebruikt door de partial.',
+ 'posts_order' => 'Volgorde artikelen',
+ 'posts_order_description' => 'Kolom waar de artikelen op gesorteerd moeten worden',
+ 'posts_category' => 'Categorie pagina',
+ 'posts_category_description' => 'Naam van categorie pagina bestand voor gekoppeld artikel overzichts pagina. Deze waarde wordt standaard gebruikt door de partial.',
+ 'posts_post' => 'Artikel pagina',
+ 'posts_post_description' => 'Naam van blog pagina bestand voor de "Lees meer" links. Deze waarde wordt standaard gebruikt door de partial.',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/pl/lang.php b/plugins/rainlab/blog/lang/pl/lang.php
new file mode 100644
index 0000000..6954898
--- /dev/null
+++ b/plugins/rainlab/blog/lang/pl/lang.php
@@ -0,0 +1,159 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Solidna platforma blogera',
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Zarządzaj postami na blogu',
+ 'posts' => 'Posty',
+ 'create_post' => 'Utwórz post',
+ 'categories' => 'Kategorie',
+ 'create_category' => 'Utwórz kategorię',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Zarządzaj postami',
+ 'access_categories' => 'Zarządzaj kategoriami na blogu',
+ 'access_other_posts' => 'Zarządzaj postami innych użytkowników',
+ 'access_import_export' => 'Zarządzaj importowaniem i eksportowaniem postów',
+ 'access_publish' => 'Publikuj posty',
+ 'manage_settings' => 'Zarządzaj ustawieniami bloga',
+ 'delete_confirm' => 'Czy jesteś pewien?',
+ 'chart_published' => 'Opublikowane',
+ 'chart_drafts' => 'Szkice',
+ 'chart_total' => 'Łącznie',
+ 'settings_description' => 'Zarządzaj ustawieniami bloga',
+ 'show_all_posts_label' => 'Pokaż wszystkie posty użytkownikom backendu',
+ 'show_all_posts_comment' => 'Wyświetl opublikowane i nieopublikowany posty na stronie dla użytkowników backendu',
+ 'tab_general' => 'Ogólne',
+ ],
+ 'posts' => [
+ 'list_title' => 'Zarządzaj postami',
+ 'filter_category' => 'Kategoria',
+ 'filter_published' => 'Ukryj opublikowane',
+ 'filter_date' => 'Date',
+ 'new_post' => 'Nowy post',
+ 'export_post' => 'Eksportuj posty',
+ 'import_post' => 'Importuj posty',
+ ],
+ 'post' => [
+ 'title' => 'Tytuł',
+ 'title_placeholder' => 'Tytuł nowego posta',
+ 'content' => 'Zawartość',
+ 'content_html' => 'Zawartość HTML',
+ 'slug' => 'Alias',
+ 'slug_placeholder' => 'alias-nowego-postu',
+ 'categories' => 'Kategorie',
+ 'author_email' => 'Email autora',
+ 'created' => 'Utworzony',
+ 'created_date' => 'Data utworzenia',
+ 'updated' => 'Zaktualizowany',
+ 'updated_date' => 'Data aktualizacji',
+ 'published' => 'Opublikowany',
+ 'published_date' => 'Data publikacji',
+ 'published_validation' => 'Proszę określić datę publikacji',
+ 'tab_edit' => 'Edytuj',
+ 'tab_categories' => 'Kategorie',
+ 'categories_comment' => 'Wybierz kategorie do których post należy',
+ 'categories_placeholder' => 'Nie ma żadnej kategorii, powinieneś utworzyć przynajmniej jedną.',
+ 'tab_manage' => 'Zarządzaj',
+ 'published_on' => 'Opublikowane',
+ 'excerpt' => 'Zalążek',
+ 'summary' => 'Summary',
+ 'featured_images' => 'Załączone grafiki',
+ 'delete_confirm' => 'Czy naprawdę chcesz usunąć ten post?',
+ 'delete_success' => 'Posty zostały pomyślnie usunięte.',
+ 'close_confirm' => 'Ten post nie jest zapisany.',
+ 'return_to_posts' => 'Wróć do listy postów',
+ ],
+ 'categories' => [
+ 'list_title' => 'Zarządzaj kategoriami postów',
+ 'new_category' => 'Nowa kategoria',
+ 'uncategorized' => 'Bez kategorii',
+ ],
+ 'category' => [
+ 'name' => 'Nazwa',
+ 'name_placeholder' => 'Nazwa nowej kategorii',
+ 'description' => 'Opis',
+ 'slug' => 'Alias',
+ 'slug_placeholder' => 'alias-nowej-kategorii',
+ 'posts' => 'Posty',
+ 'delete_confirm' => 'Czy naprawdę chcesz usunąć tę kategorię?',
+ 'delete_success' => 'Kategorie zostały pomyślnie usunięte.',
+ 'return_to_categories' => 'Wróć do listy kategorii',
+ 'reorder' => 'Zmień kolejnośc kategorii',
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Kategorie',
+ 'all_blog_categories' => 'Wszystkie kategorie',
+ 'blog_post' => 'Post na bloga',
+ 'all_blog_posts' => 'Wszystkie posty',
+ 'category_blog_posts' => 'Posty w kategorii',
+ ],
+ 'settings' => [
+ 'category_title' => 'Lista kategorii',
+ 'category_description' => 'Wyświetla listę blogowych kategorii na stronie.',
+ 'category_slug' => 'Alias kategorii',
+ 'category_slug_description' => 'Look up the blog category using the supplied slug value. This property is used by the default component partial for marking the currently active category.',
+ 'category_display_empty' => 'Pokaż puste kategorie',
+ 'category_display_empty_description' => 'Pokazuje kategorie, które nie posiadają postów',
+ 'category_page' => 'Strona kategorii',
+ 'category_page_description' => 'Nazwa strony kategorii gdzie są pokazywane linki. Ten parametr jest domyślnie używany przez komponent.',
+ 'post_title' => 'Post',
+ 'post_description' => 'Wyświetla pojedynczy post na stronie.',
+ 'post_slug' => 'Alias postu',
+ 'post_slug_description' => 'Szuka post po nazwie aliasu.',
+ 'post_category' => 'Strona kategorii',
+ 'post_category_description' => 'Nazwa strony kategorii gdzie są pokazywane linki. Ten parametr jest domyślnie używany przez komponent.',
+ 'posts_title' => 'Lista postów',
+ 'posts_description' => 'Wyświetla kilka ostatnich postów.',
+ 'posts_pagination' => 'Numer strony',
+ 'posts_pagination_description' => 'Ta wartość odpowiada za odczytanie numeru strony.',
+ 'posts_filter' => 'Filtr kategorii',
+ 'posts_filter_description' => 'Wprowadź alias kategorii lub adres URL aby filtrować posty. Pozostaw puste aby pokazać wszystkie.',
+ 'posts_per_page' => 'Ilość postów na strone',
+ 'posts_per_page_validation' => 'Nieprawidłowa wartość ilości postów na strone',
+ 'posts_no_posts' => 'Komunikat o braku postów',
+ 'posts_no_posts_description' => 'Wiadomość, która ukaże się kiedy komponent nie odnajdzie postów. Ten parametr jest domyślnie używany przez komponent.',
+ 'posts_no_posts_default' => 'Nie znaleziono postów',
+ 'posts_order' => 'Kolejność postów',
+ 'posts_order_description' => 'Parametr przez który mają być sortowane posty',
+ 'posts_category' => 'Strona kategorii',
+ 'posts_category_description' => 'Nazwa strony kategorii w wyświetlaniu linków "Posted into" [Opublikowano w]. Ten parametr jest domyślnie używany przez komponent.',
+ 'posts_post' => 'Strona postu',
+ 'posts_post_description' => 'Nazwa strony postu dla linków "Learn more" [Czytaj więcej]. Ten parametr jest domyślnie używany przez komponent.',
+ 'posts_except_post' => 'Wyklucz posty',
+ 'posts_except_post_description' => 'Wprowadź ID/URL lub zmienną z ID/URL postu, który chcesz wykluczyć',
+ 'posts_except_post_validation' => 'Wartość pola wykluczenia postów musi być pojedynczym ID/aliasem lub listą ID/aliasów rozdzieloną przecinkami',
+ 'posts_except_categories' => 'Wyklucz kategorie',
+ 'posts_except_categories_description' => 'Wprowadź listę aliasów kategorii rozdzieloną przecinkami lub zmienną zawierającą taką listę',
+ 'posts_except_categories_validation' => 'Wartośc pola wykluczenia kategorii musi być pojedynczym aliasem lub listą aliasów rozdzielonych przecinkami',
+ 'rssfeed_blog' => 'Strona bloga',
+ 'rssfeed_blog_description' => 'Nazwa strony głównej bloga do generowania linków. Używane przez domyślny fragment komponentu.',
+ 'rssfeed_title' => 'Kanał RSS',
+ 'rssfeed_description' => 'Generuje kanał RSS zawierający posty z bloga.',
+ 'group_links' => 'Linki',
+ 'group_exceptions' => 'Wyjątki',
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Tytuł (rosnąco)',
+ 'title_desc' => 'Tytuł (malejąco)',
+ 'created_asc' => 'Data utworzenia (rosnąco)',
+ 'created_desc' => 'Data utworzenia (malejąco)',
+ 'updated_asc' => 'Data aktualizacji (rosnąco)',
+ 'updated_desc' => 'Data aktualizacji (malejąco)',
+ 'published_asc' => 'Data publikacji (rosnąco)',
+ 'published_desc' => 'Data publikacji (malejąco)',
+ 'random' => 'Losowo',
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Aktualizuj istniejące wpisy',
+ 'update_existing_comment' => 'Zaznacz to pole, aby zaktualizować posty, które mają taki sam identyfikator (ID), tytuł lub alias.',
+ 'auto_create_categories_label' => 'Utwórz kategorie podane w pliku',
+ 'auto_create_categories_comment' => 'Aby skorzystać z tej funkcji powinieneś dopasować kolumnę Kategorii. W przeciwnym wypadku wybierz domyślną kategorię do użycia poniżej.',
+ 'categories_label' => 'Kategorie',
+ 'categories_comment' => 'Wybierz kategorię, do której będą należeć zaimportowane posty (opcjonalne).',
+ 'default_author_label' => 'Domyślny autor postów (opcjonalne)',
+ 'default_author_comment' => 'Import spróbuje dopasować istniejącego autora na podstawie kolumny email. W przypadku niepowodzenia zostanie użyty autor wybrany powyżej.',
+ 'default_author_placeholder' => '-- wybierz autora --',
+ ],
+];
diff --git a/plugins/rainlab/blog/lang/pt-br/lang.php b/plugins/rainlab/blog/lang/pt-br/lang.php
new file mode 100644
index 0000000..98037ce
--- /dev/null
+++ b/plugins/rainlab/blog/lang/pt-br/lang.php
@@ -0,0 +1,124 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'A plataforma de blogs robusta.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Gerencie os posts do blog',
+ 'posts' => 'Posts',
+ 'create_post' => 'Blog post',
+ 'categories' => 'Categorias',
+ 'create_category' => 'Blog categoria',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Gerencie os posts do blog',
+ 'access_categories' => 'Gerenciar as categorias de blog',
+ 'access_other_posts' => 'Gerencie outros posts de usuários do blog',
+ 'access_import_export' => 'Permissão para importação e exportação de mensagens',
+ 'access_publish' => 'Permitido publicar posts',
+ 'delete_confirm' => 'Você tem certeza?',
+ 'chart_published' => 'Publicados',
+ 'chart_drafts' => 'Rascunhos',
+ 'chart_total' => 'Total'
+ ],
+ 'posts' => [
+ 'list_title' => 'Gerencie os posts do blog',
+ 'filter_category' => 'Categoria',
+ 'filter_published' => 'Esconder publicados',
+ 'filter_date' => 'Data',
+ 'new_post' => 'Novo post',
+ 'export_post' => 'Exportar posts',
+ 'import_post' => 'Importar posts'
+ ],
+ 'post' => [
+ 'title' => 'Título',
+ 'title_placeholder' => 'Novo título do post',
+ 'content' => 'Conteúdo',
+ 'content_html' => 'HTML Conteúdo',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'slug-do-post',
+ 'categories' => 'Categorias',
+ 'author_email' => 'Autor Email',
+ 'created' => 'Criado',
+ 'created_date' => 'Data de criação',
+ 'updated' => 'Atualizado',
+ 'updated_date' => 'Data de atualização',
+ 'published' => 'Publicado',
+ 'published_date' => 'Data de publicação',
+ 'published_validation' => 'Por favor, especifique a data de publicação',
+ 'tab_edit' => 'Editar',
+ 'tab_categories' => 'Categorias',
+ 'categories_comment' => 'Selecione as categorias do blog que o post pertence.',
+ 'categories_placeholder' => 'Não há categorias, você deve criar um primeiro!',
+ 'tab_manage' => 'Gerenciar',
+ 'published_on' => 'Publicado em',
+ 'excerpt' => 'Resumo',
+ 'summary' => 'Resumo',
+ 'featured_images' => 'Imagens destacadas',
+ 'delete_confirm' => 'Você realmente deseja excluir este post?',
+ 'close_confirm' => 'O post não foi salvo.',
+ 'return_to_posts' => 'Voltar à lista de posts'
+ ],
+ 'categories' => [
+ 'list_title' => 'Gerenciar as categorias do blog',
+ 'new_category' => 'Nova categoria',
+ 'uncategorized' => 'Sem categoria'
+ ],
+ 'category' => [
+ 'name' => 'Nome',
+ 'name_placeholder' => 'Novo nome para a categoria',
+ 'description' => 'Descrição',
+ 'slug' => 'Slug',
+ 'slug_placeholder' => 'novo-slug-da-categoria',
+ 'posts' => 'Posts',
+ 'delete_confirm' => 'Você realmente quer apagar esta categoria?',
+ 'return_to_categories' => 'Voltar para a lista de categorias do blog',
+ 'reorder' => 'Reordenar Categorias'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog categoria',
+ 'all_blog_categories' => 'Todas as categorias de blog',
+ 'blog_post' => 'Blog post',
+ 'all_blog_posts' => 'Todas as postagens do blog'
+ ],
+ 'settings' => [
+ 'category_title' => 'Lista de categoria',
+ 'category_description' => 'Exibe uma lista de categorias de blog na página.',
+ 'category_slug' => 'Slug da categoria',
+ 'category_slug_description' => "Olhe para cima, a categoria do blog já está usando o valor fornecido! Esta propriedade é usada pelo componente default parcial para a marcação da categoria atualmente ativa.",
+ 'category_display_empty' => 'xibir categorias vazias',
+ 'category_display_empty_description' => 'Mostrar categorias que não tem nenhum post.',
+ 'category_page' => 'Página da categoria',
+ 'category_page_description' => 'Nome do arquivo de página da categoria para os links de categoria. Esta propriedade é usada pelo componente default parcial.',
+ 'post_title' => 'Post',
+ 'post_description' => 'Exibe um post na página.',
+ 'post_slug' => 'Post slug',
+ 'post_slug_description' => "Procure o post do blog usando o valor do slug fornecido.",
+ 'post_category' => 'Página da categoria',
+ 'post_category_description' => 'Nome do arquivo de página da categoria para os links de categoria. Esta propriedade é usada pelo componente default parcial.',
+ 'posts_title' => 'Lista de posts',
+ 'posts_description' => 'Exibe uma lista de últimas postagens na página.',
+ 'posts_pagination' => 'Número da pagina',
+ 'posts_pagination_description' => 'Esse valor é usado para determinar qual página o usuário está.',
+ 'posts_filter' => 'Filtro de categoria',
+ 'posts_filter_description' => 'Digite um slug de categoria ou parâmetro de URL para filtrar as mensagens. Deixe em branco para mostrar todas as mensagens.',
+ 'posts_per_page' => 'Posts por página',
+ 'posts_per_page_validation' => 'Formato inválido das mensagens por valor de página',
+ 'posts_no_posts' => 'Nenhuma mensagem de posts',
+ 'posts_no_posts_description' => 'Mensagem para exibir na lista post no caso, se não há mensagens. Esta propriedade é usada pelo componente default parcial.',
+ 'posts_order' => 'Orde posts',
+ 'posts_order_decription' => 'Atributo em que as mensagens devem ser ordenados',
+ 'posts_category' => 'Página de Categoria',
+ 'posts_category_description' => 'Nome do arquivo de página da categoria para os links de categoria. Esta propriedade é usada pelo componente default parcial.',
+ 'posts_post' => 'Página de posts',
+ 'posts_post_description' => 'Nome do arquivo post página para os "Saiba mais" links. Esta propriedade é usada pelo componente default parcial.',
+ 'posts_except_post' => 'Except post',
+ 'posts_except_post_description' => 'Enter ID/URL or variable with post ID/URL you want to except',
+ 'rssfeed_blog' => 'Página do Blog',
+ 'rssfeed_blog_description' => 'Nome do arquivo principal da página do blog para geração de links. Essa propriedade é usada pelo componente padrão parcial.',
+ 'rssfeed_title' => 'RSS Feed',
+ 'rssfeed_description' => 'Gera um feed RSS que contém posts do blog.'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/ru/lang.php b/plugins/rainlab/blog/lang/ru/lang.php
new file mode 100644
index 0000000..4a8be1b
--- /dev/null
+++ b/plugins/rainlab/blog/lang/ru/lang.php
@@ -0,0 +1,159 @@
+ [
+ 'name' => 'Блог',
+ 'description' => 'Надежная блоговая-платформа.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Блог',
+ 'menu_description' => 'Управление Блогом',
+ 'posts' => 'Записи',
+ 'create_post' => 'записи',
+ 'categories' => 'Категории',
+ 'create_category' => 'категории',
+ 'tab' => 'Блог',
+ 'access_posts' => 'Управление записями блога',
+ 'access_categories' => 'Управление категориями блога',
+ 'access_other_posts' => 'Управление записями других пользователей',
+ 'access_import_export' => 'Разрешено импортировать и экспортировать записи',
+ 'access_publish' => 'Разрешено публиковать записи',
+ 'manage_settings' => 'Управление настройками блога',
+ 'delete_confirm' => 'Вы уверены, что хотите сделать это?',
+ 'chart_published' => 'Опубликовано',
+ 'chart_drafts' => 'Черновики',
+ 'chart_total' => 'Всего',
+ 'settings_description' => 'Управление настройками блога',
+ 'show_all_posts_label' => 'Показывать все записи для внутренних (бэкенд) пользователей',
+ 'show_all_posts_comment' => 'Показывать опубликованные и неопубликованные записи на фронтенде для внутренних (бэкенд) пользователей',
+ 'tab_general' => 'Основные'
+ ],
+ 'posts' => [
+ 'list_title' => 'Управление записями блога',
+ 'filter_category' => 'Категория',
+ 'filter_published' => 'Скрыть опубликованные',
+ 'filter_date' => 'Дата',
+ 'new_post' => 'Новая запись',
+ 'export_post' => 'Экспорт записей',
+ 'import_post' => 'Импорт записей'
+ ],
+ 'post' => [
+ 'title' => 'Заголовок',
+ 'title_placeholder' => 'Новый заголовок записи',
+ 'content' => 'Контент',
+ 'content_html' => 'HTML Контент',
+ 'slug' => 'URL записи',
+ 'slug_placeholder' => 'new-post-slug',
+ 'categories' => 'Категории',
+ 'author_email' => 'Email автора',
+ 'created' => 'Создано',
+ 'created_date' => 'Дата создания',
+ 'updated' => 'Обновлено',
+ 'updated_date' => 'Дата обновления',
+ 'published' => 'Опубликовано',
+ 'published_date' => 'Дата публикации',
+ 'published_validation' => 'Пожалуйста, укажите дату публикации.',
+ 'tab_edit' => 'Редактор',
+ 'tab_categories' => 'Категории',
+ 'categories_comment' => 'Выберите категории, к которым относится эта запись',
+ 'categories_placeholder' => 'Не найдено ни одной категории, создайте хотя бы одну!',
+ 'tab_manage' => 'Управление',
+ 'published_on' => 'Опубликовано',
+ 'excerpt' => 'Отрывок',
+ 'summary' => 'Резюме',
+ 'featured_images' => 'Тематические изображения',
+ 'delete_confirm' => 'Вы действительно хотите удалить эту запись?',
+ 'delete_success' => 'Эти записи успешно удалены.',
+ 'close_confirm' => 'Запись не была сохранена.',
+ 'return_to_posts' => 'Вернуться к списку записей'
+ ],
+ 'categories' => [
+ 'list_title' => 'Управление категориями блога',
+ 'new_category' => 'Новая категория',
+ 'uncategorized' => 'Без категории'
+ ],
+ 'category' => [
+ 'name' => 'Название',
+ 'name_placeholder' => 'Новое имя категории',
+ 'description' => 'Описание',
+ 'slug' => 'URL адрес',
+ 'slug_placeholder' => 'new-category-slug',
+ 'posts' => 'Записи',
+ 'delete_confirm' => 'Вы действительно хотите удалить эту категорию?',
+ 'delete_success' => 'Эти категории успешно удалены.',
+ 'return_to_categories' => 'Вернуться к списку категорий',
+ 'reorder' => 'Порядок категорий'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Категория блога',
+ 'all_blog_categories' => 'Все категории блога',
+ 'blog_post' => 'Запись блога',
+ 'all_blog_posts' => 'Все записи блога',
+ 'category_blog_posts' => 'Записи категории блога'
+ ],
+ 'settings' => [
+ 'category_title' => 'Список категорий блога',
+ 'category_description' => 'Отображает список категорий на странице.',
+ 'category_slug' => 'Параметр URL',
+ 'category_slug_description' => 'Параметр маршрута, используемый для поиска в текущей категории по URL. Это свойство используется по умолчанию компонентом Фрагменты для маркировки активной категории.',
+ 'category_display_empty' => 'Пустые категории',
+ 'category_display_empty_description' => 'Отображать категории, которые не имеют записей.',
+ 'category_page' => 'Страница категорий',
+ 'category_page_description' => 'Название страницы категорий. Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'post_title' => 'Запись блога',
+ 'post_description' => 'Отображение записи блога',
+ 'post_slug' => 'Параметр URL',
+ 'post_slug_description' => 'Параметр маршрута, необходимый для выбора конкретной записи.',
+ 'post_category' => 'Страница категорий',
+ 'post_category_description' => 'Название страницы категорий. Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'posts_title' => 'Список записей блога',
+ 'posts_description' => 'Отображает список последних записей блога на странице.',
+ 'posts_pagination' => 'Параметр постраничной навигации',
+ 'posts_pagination_description' => 'Параметр, необходимый для постраничной навигации.',
+ 'posts_filter' => 'Фильтр категорий',
+ 'posts_filter_description' => 'Введите URL категории или параметр URL-адреса для фильтрации записей. Оставьте пустым, чтобы посмотреть все записи.',
+ 'posts_per_page' => 'Записей на странице',
+ 'posts_per_page_validation' => 'Недопустимый Формат. Ожидаемый тип данных - действительное число.',
+ 'posts_no_posts' => 'Отсутствие записей',
+ 'posts_no_posts_description' => 'Сообщение, отображаемое в блоге, если отсутствуют записи. Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'posts_no_posts_default' => 'Записей не найдено',
+ 'posts_order' => 'Сортировка',
+ 'posts_order_description' => 'Атрибут, по которому будут сортироваться записи.',
+ 'posts_category' => 'Страница категорий',
+ 'posts_category_description' => 'Название категории на странице записи "размещена в категории". Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'posts_post' => 'Страница записи',
+ 'posts_post_description' => 'Название страницы для ссылки "подробнее". Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'posts_except_post' => 'Кроме записи',
+ 'posts_except_post_description' => 'Введите ID/URL или переменную с ID/URL записи, которую вы хотите исключить',
+ 'posts_except_categories' => 'Кроме категорий',
+ 'posts_except_categories_description' => 'Введите разделенный запятыми список URL категорий или переменную со списком категорий, которые вы хотите исключить',
+ 'rssfeed_blog' => 'Страница блога',
+ 'rssfeed_blog_description' => 'Имя основного файла страницы блога для генерации ссылок. Это свойство используется по умолчанию компонентом Фрагменты.',
+ 'rssfeed_title' => 'RSS Feed',
+ 'rssfeed_description' => 'Создает RSS-канал, содержащий записи из блога.',
+ 'group_links' => 'Ссылки',
+ 'group_exceptions' => 'Исключения'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Заголовок (по возрастанию)',
+ 'title_desc' => 'Заголовок (по убыванию)',
+ 'created_asc' => 'Создано (по возрастанию)',
+ 'created_desc' => 'Создано (по убыванию)',
+ 'updated_asc' => 'Обновлено (по возрастанию)',
+ 'updated_desc' => 'Обновлено (по убыванию)',
+ 'published_asc' => 'Опубликовано (по возрастанию)',
+ 'published_desc' => 'Опубликовано (по убыванию)',
+ 'random' => 'Случайно'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Обновить существующие записи',
+ 'update_existing_comment' => 'Установите этот флажок, чтобы обновлять записи имеющие одинаковый ID, title или URL.',
+ 'auto_create_categories_label' => 'Создать категории указанные в импортируемом файле',
+ 'auto_create_categories_comment' => 'Вы должны сопоставить столбец Категории, чтобы использовать эту функцию. В противном случае выберите для назначения категорию по умолчанию из пунктов ниже.',
+ 'categories_label' => 'Категории',
+ 'categories_comment' => 'Выберите категории, к которым будут принадлежать импортированные записи (необязательно).',
+ 'default_author_label' => 'Автор записи по умолчанию (необязательно)',
+ 'default_author_comment' => 'Импорт попытается использовать существующего автора, если он соответствуете столбцу Email автора, в противном случае используется указанный выше автор.',
+ 'default_author_placeholder' => '-- выберите автора --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/sk/lang.php b/plugins/rainlab/blog/lang/sk/lang.php
new file mode 100644
index 0000000..5756778
--- /dev/null
+++ b/plugins/rainlab/blog/lang/sk/lang.php
@@ -0,0 +1,154 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Robustná blogová platforma.'
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Správa blogových príspevkov',
+ 'posts' => 'Príspevky',
+ 'create_post' => 'Príspevok',
+ 'categories' => 'Kategórie',
+ 'create_category' => 'Kategórie príspevkov',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Správa blogových príspevkov',
+ 'access_categories' => 'Správa blogových kategórií',
+ 'access_other_posts' => 'Správa blogových príspevkov ostatných užívateľov',
+ 'access_import_export' => 'Možnosť importu a exportu príspevkov',
+ 'access_publish' => 'Možnosť publikovať príspevky',
+ 'delete_confirm' => 'Ste si istý?',
+ 'chart_published' => 'PublikovanéPublished',
+ 'chart_drafts' => 'Koncepty',
+ 'chart_total' => 'Celkom'
+ ],
+ 'posts' => [
+ 'list_title' => 'Správa blogových príspevkov',
+ 'filter_category' => 'Kategória',
+ 'filter_published' => 'Publikované',
+ 'filter_date' => 'Dátum',
+ 'new_post' => 'Nový príspevok',
+ 'export_post' => 'Exportovať príspevky',
+ 'import_post' => 'Importovať príspevky'
+ ],
+ 'post' => [
+ 'title' => 'Názov',
+ 'title_placeholder' => 'Názov nového príspevku',
+ 'content' => 'Obsah',
+ 'content_html' => 'HTML Obsah',
+ 'slug' => 'URL príspevku',
+ 'slug_placeholder' => 'url-nového-príspevku',
+ 'categories' => 'Kategórie',
+ 'author_email' => 'Email autora',
+ 'created' => 'Vytvorené',
+ 'created_date' => 'Dátum vytvorenia',
+ 'updated' => 'Upravené',
+ 'updated_date' => 'Dátum upravenia',
+ 'published' => 'Publikované',
+ 'published_date' => 'Dátum publikovania',
+ 'published_validation' => 'Prosím zvoľte dátum publikovania príspevku',
+ 'tab_edit' => 'Upraviť',
+ 'tab_categories' => 'Kategórie',
+ 'categories_comment' => 'Vyberte kategórie do ktorých tento príspevok patrí',
+ 'categories_placeholder' => 'Neexistujú žiadne kategórie, najprv nejakú vytvorte!',
+ 'tab_manage' => 'Nastavenia',
+ 'published_on' => 'Dátum publikovania',
+ 'excerpt' => 'Výňatok príspevku',
+ 'summary' => 'Zhrnutie',
+ 'featured_images' => 'Obrázky',
+ 'delete_confirm' => 'Zmazať tento príspevok?',
+ 'delete_success' => 'Vybrané príspevky boli úspešne odstránené.',
+ 'close_confirm' => 'Príspevok nie je uložený.',
+ 'return_to_posts' => 'Späť na zoznam príspevkov'
+ ],
+ 'categories' => [
+ 'list_title' => 'Správa blogových kategórií',
+ 'new_category' => 'Nová kategória',
+ 'uncategorized' => 'Nezaradené'
+ ],
+ 'category' => [
+ 'name' => 'Názov',
+ 'name_placeholder' => 'Názov novej kategórie',
+ 'description' => 'Popis',
+ 'slug' => 'URL kategórie',
+ 'slug_placeholder' => 'url-novej-kategórie',
+ 'posts' => 'Počet príspevkov',
+ 'delete_confirm' => 'Zmazať túto kategóriu?',
+ 'delete_success' => 'Vybrané kategórie boli úspešne odstránené.',
+ 'return_to_categories' => 'Späť na zoznam kategórií',
+ 'reorder' => 'Zmeniť poradie kategórií'
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blogová kategória',
+ 'all_blog_categories' => 'Všetky blogové kategórie',
+ 'blog_post' => 'Blogové príspevky',
+ 'all_blog_posts' => 'Všetky blogové príspevky',
+ 'category_blog_posts' => 'Blogové príspevky v kategórií'
+ ],
+ 'settings' => [
+ 'category_title' => 'Zoznam kategórií',
+ 'category_description' => 'Zobrazí zoznam blogových kategórií na stránke.',
+ 'category_slug' => 'URL kategórie',
+ 'category_slug_description' => "Nájde blogovú kategóriu s týmto URL. Používa sa pre zobrazenie aktívnej kategórie.",
+ 'category_display_empty' => 'Zobraziť prázdne kategórie',
+ 'category_display_empty_description' => 'Zobrazí kategórie, ktoré nemajú žiadne príspevky.',
+ 'category_page' => 'Stránka kategórie',
+ 'category_page_description' => 'Názov stránky kategórie kam budú smerovať odkazy na kategóriu. Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'post_title' => 'Príspevok',
+ 'post_description' => 'Zobrazí blogový príspevok na stránke.',
+ 'post_slug' => 'URL príspevku',
+ 'post_slug_description' => "Nájde blogový príspevok s týmto URL.",
+ 'post_category' => 'Stránka kategórie',
+ 'post_category_description' => 'Názov stránky kategórie kam budú smerovať odkazy na kategóriu. Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'posts_title' => 'Zoznam príspevkov',
+ 'posts_description' => 'Zobrazí zoznam blogových príspevkov na stránke.',
+ 'posts_pagination' => 'číslo stránky',
+ 'posts_pagination_description' => 'Táto hodnota je použitá na určenie na akej stránke sa užívateľ nachádza.',
+ 'posts_filter' => 'Filter kategórií',
+ 'posts_filter_description' => 'Zadajte URL kategórie alebo URL parameter na filtrovanie príspevkov. Nechajte prázdne pre zobrazenie všetkých príspevkov.',
+ 'posts_per_page' => 'Príspevkov na stránku',
+ 'posts_per_page_validation' => 'Neplatný formát hodnoty počtu príspevkov na stránku',
+ 'posts_no_posts' => 'Správa prázdnej stránky',
+ 'posts_no_posts_description' => 'Správa, ktorá bude zobrazená v zozname príspevkov v prípade, že nie sú žiadne na zobrazenie. Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'posts_no_posts_default' => 'Nenašli sa žiadne príspevky',
+ 'posts_order' => 'Zoradenie príspevkov',
+ 'posts_order_description' => 'Atribút podľa ktorého budú príspevky zoradené',
+ 'posts_category' => 'Stránka kategórie',
+ 'posts_category_description' => 'Názov stránky kategórie kam budú smerovať odkazy "Vložené do". Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'posts_post' => 'Stránka príspevku',
+ 'posts_post_description' => 'Názov stránky príspevku kam budú smerovať odkazy "Zistiť viac". Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'posts_except_post' => 'Okrem príspevku',
+ 'posts_except_post_description' => 'Zadajte ID/URL alebo premennú s ID/URL príspevku, ktorý chcete vylúčiť',
+ 'posts_except_categories' => 'Okrem kategórií',
+ 'posts_except_categories_description' => 'Zadajte zoznam kategórií oddelený čiarkami alebo premennú s týmto zoznamom, ktoré chcete vylúčiť',
+ 'rssfeed_blog' => 'Stránka blogu',
+ 'rssfeed_blog_description' => 'Názov hlavnej stránky blogu na generovanie odkazov. Táto hodnota je použitá v predvolenej čiastočnej stránke komponentu.',
+ 'rssfeed_title' => 'RSS Kanál',
+ 'rssfeed_description' => 'Vygeneruje RSS kanál, ktorý obsahuje blogové príspevky.',
+ 'group_links' => 'Odkazy',
+ 'group_exceptions' => 'Výnimky'
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Názov (vzostupne)',
+ 'title_desc' => 'Názov (zostupne)',
+ 'created_asc' => 'Vytvorené (vzostupne)',
+ 'created_desc' => 'Vytvorené (zostupne)',
+ 'updated_asc' => 'Upravené (vzostupne)',
+ 'updated_desc' => 'Upravené (zostupne)',
+ 'published_asc' => 'Publikované (vzostupne)',
+ 'published_desc' => 'Publikované (zostupne)',
+ 'random' => 'Náhodne'
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Aktualizovať existujúce príspevky',
+ 'update_existing_comment' => 'Začiarknutím tohto políčka aktualizujte príspevky, ktoré majú presne to isté ID, titul alebo URL príspevku.',
+ 'auto_create_categories_label' => 'Vytvoriť kategórie zadané v importovanom súbore',
+ 'auto_create_categories_comment' => 'Ak chcete túto funkciu použiť, mali by sa zhodovať so stĺpcom Kategórie, inak vyberte predvolené kategórie, ktoré chcete použiť z nižšie uvedených položiek.',
+ 'categories_label' => 'Kategórie',
+ 'categories_comment' => 'Vyberte kategórie, do ktorých budú patriť importované príspevky (voliteľné).',
+ 'default_author_label' => 'Predvolený autor príspevku (voliteľné)',
+ 'default_author_comment' => 'Import sa pokúsi použiť existujúceho autora, ak sa zhoduje so stĺpcom e-mail, inak sa použije vyššie uvedený autor.',
+ 'default_author_placeholder' => '-- vyberte autora --'
+ ]
+];
diff --git a/plugins/rainlab/blog/lang/sl/lang.php b/plugins/rainlab/blog/lang/sl/lang.php
new file mode 100644
index 0000000..d0c3c12
--- /dev/null
+++ b/plugins/rainlab/blog/lang/sl/lang.php
@@ -0,0 +1,163 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Robustna platforma za bloganje.',
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Upravljanje bloga',
+ 'posts' => 'Objave',
+ 'create_post' => 'Blog objava',
+ 'categories' => 'Kategorije',
+ 'create_category' => 'Blog kategorija',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Upravljanje blog objav',
+ 'access_categories' => 'Upravljanje blog kategorij',
+ 'access_other_posts' => 'Upravljanje objav drugih uporabnikov',
+ 'access_import_export' => 'Dovoljenje za uvoz in izvoz objav',
+ 'access_publish' => 'Dovoljenje za objavljanje objav',
+ 'manage_settings' => 'Urejanje nastavitev bloga',
+ 'delete_confirm' => 'Ali ste prepričani?',
+ 'chart_published' => 'Objavljeno',
+ 'chart_drafts' => 'Osnutki',
+ 'chart_total' => 'Skupaj',
+ 'settings_description' => 'Urejanje nastavitev bloga',
+ 'show_all_posts_label' => 'Administratorjem prikaži vse objave',
+ 'show_all_posts_comment' => 'Na spletni strani prikaži administratorjem vse objavljene in neobjavljene objave.',
+ 'tab_general' => 'Splošno',
+ ],
+ 'posts' => [
+ 'list_title' => 'Urejanje blog objav',
+ 'filter_category' => 'Kategorija',
+ 'filter_published' => 'Objavljeno',
+ 'filter_date' => 'Datum',
+ 'new_post' => 'Nova objava',
+ 'export_post' => 'Izvoz objav',
+ 'import_post' => 'Uvoz objav',
+ ],
+ 'post' => [
+ 'title' => 'Naslov',
+ 'title_placeholder' => 'Naslov nove objave',
+ 'content' => 'Vsebina',
+ 'content_html' => 'HTML vsebina',
+ 'slug' => 'Povezava',
+ 'slug_placeholder' => 'povezava-nove-objave',
+ 'categories' => 'Kategorije',
+ 'author_email' => 'E-pošta avtorja',
+ 'created' => 'Ustvarjeno',
+ 'created_date' => 'Ustvarjeno dne',
+ 'updated' => 'Posodobljeno',
+ 'updated_date' => 'Posodobljeno dne',
+ 'published' => 'Objavljeno',
+ 'published_by' => 'Objavil',
+ 'current_user' => 'Trenutni uporabnik',
+ 'published_date' => 'Datum objave',
+ 'published_validation' => 'Prosimo, podajte datum objave',
+ 'tab_edit' => 'Upravljanje',
+ 'tab_categories' => 'Kategorije',
+ 'categories_comment' => 'Izberite kategorije, v katere spada objava',
+ 'categories_placeholder' => 'Ni najdenih kategorij, ustvarite vsaj eno kategorijo!',
+ 'tab_manage' => 'Urejanje',
+ 'published_on' => 'Datum objave',
+ 'excerpt' => 'Izvleček',
+ 'summary' => 'Povzetek',
+ 'featured_images' => 'Uporabljene slike',
+ 'delete_confirm' => 'Želite izbrisati to objavo?',
+ 'delete_success' => 'Te objave so bile uspešno izbrisane.',
+ 'close_confirm' => 'Ta objava ni shranjena.',
+ 'return_to_posts' => 'Vrnite se na seznam objav',
+ ],
+ 'categories' => [
+ 'list_title' => 'Upravljanje blog kategorij',
+ 'new_category' => 'Nova kategorija',
+ 'uncategorized' => 'Nekategorizirano',
+ ],
+ 'category' => [
+ 'name' => 'Naslov',
+ 'name_placeholder' => 'Naslov nove kategorije',
+ 'description' => 'Opis',
+ 'slug' => 'Povezava',
+ 'slug_placeholder' => 'povezava-nove-kategorije',
+ 'posts' => 'Objave',
+ 'delete_confirm' => 'Želite izbrisati to kategorijo?',
+ 'delete_success' => 'Te kategorije so bile uspešno izbrisane.',
+ 'return_to_categories' => 'Vrnite se na seznam blog kategorij',
+ 'reorder' => 'Spremenite vrstni red kategorij',
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog kategorija',
+ 'all_blog_categories' => 'Vse blog kategorije',
+ 'blog_post' => 'Blog objava',
+ 'all_blog_posts' => 'Vse blog objave',
+ 'category_blog_posts' => 'Objave v kategoriji',
+ ],
+ 'settings' => [
+ 'category_title' => 'Seznam kategorij',
+ 'category_description' => 'Prikaži seznam blog kategorij na strani.',
+ 'category_slug' => 'Povezava kategorije',
+ 'category_slug_description' => 'Omogoča pregled blog kategorije na podani povezavi. Ta lastnost je uporabljena v privzeti predlogi komponente za označevanje trenutno aktivne kategorije.',
+ 'category_display_empty' => 'Prikaži prazne kategorije',
+ 'category_display_empty_description' => 'Prikaže kategorije brez objav.',
+ 'category_page' => 'Stran s kategorijo',
+ 'category_page_description' => 'Naslov strani blog kategorij za ustvarjanje povezav. Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'post_title' => 'Objava',
+ 'post_description' => 'Prikaži blog objavo na strani.',
+ 'post_slug' => 'Povezava objave',
+ 'post_slug_description' => "Omogoča pregled blog objave na podani povezavi.",
+ 'post_category' => 'Stran s kategorijo',
+ 'post_category_description' => 'Naslov strani blog kategorije za ustvarjanje povezav. Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'posts_title' => 'Seznam objav',
+ 'posts_description' => 'Prikaži seznam najnovejših blog objav na strani.',
+ 'posts_pagination' => 'Številka strani',
+ 'posts_pagination_description' => 'Ta vrednost se uporablja za določitev, na kateri strani se nahaja uporabnik.',
+ 'posts_filter' => 'Filter kategorij',
+ 'posts_filter_description' => 'Vnesite povezavo kategorije ali URL parameter za filtriranje objav. Pustite prazno za prikaz vseh objav.',
+ 'posts_per_page' => 'Število objav na strani',
+ 'posts_per_page_validation' => 'Neveljaven format števila objav na strani',
+ 'posts_no_posts' => 'Sporočilo brez objav',
+ 'posts_no_posts_description' => 'Sporočilo, ki se prikaže na seznamu blog objav, če ni nobenih objav. Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'posts_no_posts_default' => 'Ni najdenih objav',
+ 'posts_order' => 'Vrstni red objav',
+ 'posts_order_description' => 'Lastnost, glede na katero naj bodo razvrščene objave',
+ 'posts_category' => 'Stran s kategorijo',
+ 'posts_category_description' => 'Naslov strani blog kategorije za povezave "Objavljeno v". Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'posts_post' => 'Stran z objavo',
+ 'posts_post_description' => 'Naslov strani blog objave za povezave "Preberi več". Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'posts_except_post' => 'Izvzete objave',
+ 'posts_except_post_description' => 'Vnesite ID/URL objave ali spremenljivko z ID-jem/URL-jem objave, ki jo želite izvzeti. Za določitev večjega števila objav lahko uporabite seznam, ločen z vejicami.',
+ 'posts_except_post_validation' => 'Izvzete objave morajo biti posamezna povezava ali ID objave ali pa seznam povezav oz. ID-jev objav, ločen z vejicami.',
+ 'posts_except_categories' => 'Izvzete kategorije',
+ 'posts_except_categories_description' => 'Vnesite seznam povezav kategorij, ločen z vejicami ali pa spremenljivko, ki vključuje takšen seznam kategorij, ki jih želite izvzeti.',
+ 'posts_except_categories_validation' => 'Izvzete kategorije morajo biti posamezna povezava kategorije ali pa seznam povezav kategorij, ločen z vejicami.',
+ 'rssfeed_blog' => 'Stran z blogom',
+ 'rssfeed_blog_description' => 'Naslov glavne strani bloga za ustvarjanje povezav. Ta lastnost je uporabljena v privzeti predlogi komponente.',
+ 'rssfeed_title' => 'RSS vir',
+ 'rssfeed_description' => 'Ustvari vir RSS, ki vsebuje objave iz bloga.',
+ 'group_links' => 'Povezave',
+ 'group_exceptions' => 'Izjeme',
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Naslov (naraščajoče)',
+ 'title_desc' => 'Naslov (padajoče)',
+ 'created_asc' => 'Ustvarjeno (naraščajoče)',
+ 'created_desc' => 'Ustvarjeno (padajoče)',
+ 'updated_asc' => 'Posodobljeno (naraščajoče)',
+ 'updated_desc' => 'Posodobljeno (padajoče)',
+ 'published_asc' => 'Objavljeno (naraščajoče)',
+ 'published_desc' => 'Objavljeno (padajoče)',
+ 'random' => 'Naključno',
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Posodobi obstoječe objave',
+ 'update_existing_comment' => 'Označite kvadratek, če želite posodobiti objave, ki imajo popolnoma enak ID, naslov ali povezavo.',
+ 'auto_create_categories_label' => 'Ustvari kategorije, določene v uvozni datoteki',
+ 'auto_create_categories_comment' => "Za uporabo te možnosti morate ali povezati stolpec 'Kategorije' ali pa označiti privzete kategorije za uporabo iz spodnjega seznama.",
+ 'categories_label' => 'Kategorije',
+ 'categories_comment' => 'Izberite kategorije, na katere bodo povezavne uvožene objave (neobvezno).',
+ 'default_author_label' => 'Privzeti avtor objave (neobvezno)',
+ 'default_author_comment' => "Uvoz bo poskusil uporabiti obstoječega avtorja, če povežete stolpec 'E-pošta avtorja', sicer se bo uporabil zgoraj navedeni avtor.",
+ 'default_author_placeholder' => '-- izberite avtorja --',
+ ],
+];
diff --git a/plugins/rainlab/blog/lang/tr/lang.php b/plugins/rainlab/blog/lang/tr/lang.php
new file mode 100644
index 0000000..25f23f9
--- /dev/null
+++ b/plugins/rainlab/blog/lang/tr/lang.php
@@ -0,0 +1,161 @@
+ [
+ 'name' => 'Blog',
+ 'description' => 'Sağlam blog platformu.',
+ ],
+ 'blog' => [
+ 'menu_label' => 'Blog',
+ 'menu_description' => 'Blog Gönderilerini Yönet',
+ 'posts' => 'Gönderiler',
+ 'create_post' => 'Blog gönderisi',
+ 'categories' => 'Kategoriler',
+ 'create_category' => 'Blog kategorisi',
+ 'tab' => 'Blog',
+ 'access_posts' => 'Gönderileri yönetebilsin',
+ 'access_categories' => 'Blog kategorilerini yönetebilsin',
+ 'access_other_posts' => 'Diğer kullanıcıların gönderilerini yönetebilsin',
+ 'access_import_export' => 'Gönderileri içeri/dışarı aktarabilsin',
+ 'access_publish' => 'Gönderi yayınlayabilsin',
+ 'manage_settings' => 'Blog ayarlarını yönet',
+ 'delete_confirm' => 'Emin misiniz?',
+ 'chart_published' => 'Yayınlandı',
+ 'chart_drafts' => 'Taslaklar',
+ 'chart_total' => 'Toplam',
+ 'settings_description' => 'Blog ayarlarını yönet',
+ 'show_all_posts_label' => 'Tüm gönderileri yönetim paneli kullanıcılarına göster',
+ 'show_all_posts_comment' => 'Hem yayınlanmış hem de yayınlanmamış gönderileri önyüzde yönetim paneli kullanıcılarına göster.',
+ 'tab_general' => 'Genel',
+ ],
+ 'posts' => [
+ 'list_title' => 'Blog gönderilerini yönet',
+ 'filter_category' => 'Kategori',
+ 'filter_published' => 'Yayınlanan',
+ 'filter_date' => 'Tarih',
+ 'new_post' => 'Yeni gönderi',
+ 'export_post' => 'Gönderileri dışarı aktar',
+ 'import_post' => 'Gönderileri içeri aktar',
+ ],
+ 'post' => [
+ 'title' => 'Başlık',
+ 'title_placeholder' => 'Yeni gönderi başlığı',
+ 'content' => 'İçerik',
+ 'content_html' => 'HTML İçeriği',
+ 'slug' => 'Kısa URL',
+ 'slug_placeholder' => 'yeni-gonderi-basligi',
+ 'categories' => 'Kategoriler',
+ 'author_email' => 'Yazar E-mail',
+ 'created' => 'Oluşturuldu',
+ 'created_date' => 'Oluşturulma tarihi',
+ 'updated' => 'Güncellendi',
+ 'updated_date' => 'Güncellenme tarihi',
+ 'published' => 'Yayınlandı',
+ 'published_date' => 'Yayınlanma tarihi',
+ 'published_validation' => 'Lütfen yayınlama tarihini belirtiniz',
+ 'tab_edit' => 'Düzenle',
+ 'tab_categories' => 'Kategoriler',
+ 'categories_comment' => 'Gönderinin ait olduğu kategorileri seçiniz',
+ 'categories_placeholder' => 'Kategori yok, öncelikle bir kategori oluşturmalısınız!',
+ 'tab_manage' => 'Yönet',
+ 'published_on' => 'Yayınlandı',
+ 'excerpt' => 'Alıntı',
+ 'summary' => 'Özet',
+ 'featured_images' => 'Öne Çıkan Görseller',
+ 'delete_confirm' => 'Bu yazıyı silmek istiyor musunuz?',
+ 'delete_success' => 'Gönderi(ler) silindi.',
+ 'close_confirm' => 'Gönderi kaydedilmedi.',
+ 'return_to_posts' => 'Gönderi listesine dön',
+ ],
+ 'categories' => [
+ 'list_title' => 'Blog kategorilerini yönet',
+ 'new_category' => 'Yeni kategori',
+ 'uncategorized' => 'Kategorisiz',
+ ],
+ 'category' => [
+ 'name' => 'İsim',
+ 'name_placeholder' => 'Yeni kategori adı',
+ 'description' => 'Açıklama',
+ 'slug' => 'Kısa URL',
+ 'slug_placeholder' => 'yeni-kategori-basligi',
+ 'posts' => 'Gönderiler',
+ 'delete_confirm' => 'Bu kategoriyi silmek istiyor musunuz?',
+ 'delete_success' => 'Kategori(ler) silindi.',
+ 'return_to_categories' => 'Kategori listesine dön',
+ 'reorder' => 'Kategorileri yeniden sırala',
+ ],
+ 'menuitem' => [
+ 'blog_category' => 'Blog kategorisi',
+ 'all_blog_categories' => 'Tüm blog kategorileri',
+ 'blog_post' => 'Blog gönderisi',
+ 'all_blog_posts' => 'Tüm blog gönderileri',
+ 'category_blog_posts' => 'Blog kategori gönderileri',
+ ],
+ 'settings' => [
+ 'category_title' => 'Kategori Listesi',
+ 'category_description' => 'Kategorilerin listesini sayfada göster.',
+ 'category_slug' => 'Kategori Kısa URL',
+ 'category_slug_description' => 'Verilen kısa URLi kullanarak blog kategorisini görüntüle. Bu özellik şu anki aktif kategoriyi işaretlemek için varsayılan kısmi bileşeni tarafından kullanılır',
+ 'category_display_empty' => 'Boş kategorileri göster',
+ 'category_display_empty_description' => 'Herhangi bir gönderi olmayan kategorileri göster.',
+ 'category_page' => 'Kategori sayfası',
+ 'category_page_description' => 'Kategori bağlantıları için kategori sayfası dosyasının adı. Bu özellik varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'post_title' => 'Gönderi',
+ 'post_description' => 'Sayfada bir blog gönderisi gösterir.',
+ 'post_slug' => 'Gönderi Kısa URL',
+ 'post_slug_description' => 'Verilen kısa URL ile blog gönderisine bakın.',
+ 'post_category' => 'Kategori sayfası',
+ 'post_category_description' => 'Kategori bağlantıları için kategori sayfası dosyasının adı. Bu özellik varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'posts_title' => 'Gönderi listesi',
+ 'posts_description' => 'Sayfada son blog gönderilerinin listesini gösterir.',
+ 'posts_pagination' => 'Sayfa numarası',
+ 'posts_pagination_description' => 'Bu değer kullanıcının hangi sayfada olduğunu belirlemek için kullanılır.',
+ 'posts_filter' => 'Kategori filtresi',
+ 'posts_filter_description' => 'Gönderileri filtrelemek için kategori kısa URLsi ya da URL parametresi girin. Tüm gönderiler için boş bırakın.',
+ 'posts_per_page' => 'Sayfa başına gönderi',
+ 'posts_per_page_validation' => 'Sayfa başına gönderi için geçersiz format',
+ 'posts_no_posts' => 'Gönderi mesajı yok',
+ 'posts_no_posts_description' => 'Eğer bir gönderi yoksa gönderi listesinde görüntülenecek mesaj. Bu özellik varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'posts_no_posts_default' => 'Gönderi yok',
+ 'posts_order' => 'Gönderi Sırası',
+ 'posts_order_description' => 'Gönderilerin sıralama türü',
+ 'posts_category' => 'Kategori sayfası',
+ 'posts_category_description' => '"Yayınlanan" kategori bağlantıları için kategori sayfası dosyasının adı. Bu özellik varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'posts_post' => 'Gönderi sayfası',
+ 'posts_post_description' => '"Daha fazla bilgi edinin" bağlantıları için gönderi sayfası dosyasının adı. Bu özellik varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'posts_except_post' => 'Hariç tutulacak gönderi',
+ 'posts_except_post_description' => 'Hariç tutmak istediğiniz gönderinin ID/URL ini veya ID/URL içeren bir değişken girin. Birden çok gönderi belirtmek için virgülle ayrılmış liste kullanabilirsiniz.',
+ 'posts_except_post_validation' => 'Hariç tutulacak gönderi değeri tek bir kısa URL veya ID, veya virgülle ayrılmış kısa URL veya ID listesi olmalıdır.',
+ 'posts_except_categories' => 'Hariç tutulacak kategoriler',
+ 'posts_except_categories_description' => 'Hariç tutmak istediğiniz kategori listesini içeren virgülle ayrılmış bir kategori listesi veya listeyi içeren bir değişken girin.',
+ 'posts_except_categories_validation' => 'Hariç tutulacak kategoriler değeri tek bir kısa URL, veya virgülle ayrılmış kısa URL listesi olmalıdır.',
+ 'rssfeed_blog' => 'Blog sayfası',
+ 'rssfeed_blog_description' => 'Linkleri üretmek için ana blog sayfasının adı. Bu özellik, varsayılan kısmi bileşeni tarafından kullanılır.',
+ 'rssfeed_title' => 'RSS Beslemesi',
+ 'rssfeed_description' => 'Blog içerisindeki gönderileri veren RSS beslemesi oluşturur.',
+ 'group_links' => 'Linkler',
+ 'group_exceptions' => 'Hariç olanlar',
+ ],
+ 'sorting' => [
+ 'title_asc' => 'Başlık (a-z)',
+ 'title_desc' => 'Başlık (z-a)',
+ 'created_asc' => 'Oluşturulma (yeniden eskiye)',
+ 'created_desc' => 'Oluşturulma (eskiden yeniye)',
+ 'updated_asc' => 'Güncellenme (yeniden eskiye)',
+ 'updated_desc' => 'Güncellenme (eskiden yeniye)',
+ 'published_asc' => 'Yayınlanma (yeniden eskiye)',
+ 'published_desc' => 'Yayınlanma (eskiden yeniye)',
+ 'random' => 'Rastgele',
+ ],
+ 'import' => [
+ 'update_existing_label' => 'Mevcut gönderileri güncelle',
+ 'update_existing_comment' => 'Tam olarak aynı ID, başlık veya kısa URL içeren gönderileri güncellemek için bu kutuyu işaretleyin.',
+ 'auto_create_categories_label' => 'İçe aktarma dosyasında bulunan kategorileri oluştur',
+ 'auto_create_categories_comment' => 'Bu özelliği kullanmak için Kategoriler sütununu eşleştirmelisiniz, aksi takdirde aşağıdaki öğelerden kullanılacak varsayılan kategorileri seçmelisiniz.',
+ 'categories_label' => 'Kategoriler',
+ 'categories_comment' => 'İçe aktarılan gönderilerin ait olacağı kategorileri seçin (isteğe bağlı).',
+ 'default_author_label' => 'Varsayılan gönderi yazarı (isteğe bağlı)',
+ 'default_author_comment' => 'İçe aktarma, Yazar E-postası sütunuyla eşleşirse mevcut bir yazarı gönderi için kullanmaya çalışır, aksi takdirde yukarıda belirtilen yazar seçilir.',
+ 'default_author_placeholder' => '-- yazar seçin --',
+ ],
+];
\ No newline at end of file
diff --git a/plugins/rainlab/blog/lang/zh-cn/lang.php b/plugins/rainlab/blog/lang/zh-cn/lang.php
new file mode 100644
index 0000000..f1e2e3d
--- /dev/null
+++ b/plugins/rainlab/blog/lang/zh-cn/lang.php
@@ -0,0 +1,124 @@
+ [
+ 'name' => '博客',
+ 'description' => '一个强大的博客平台.'
+ ],
+ 'blog' => [
+ 'menu_label' => '博客',
+ 'menu_description' => '管理博客帖子',
+ 'posts' => '帖子',
+ 'create_post' => '博客帖子',
+ 'categories' => '分类',
+ 'create_category' => '博客分类',
+ 'tab' => '博客',
+ 'access_posts' => '管理博客帖子',
+ 'access_categories' => '管理博客分类',
+ 'access_other_posts' => '管理其他用户帖子',
+ 'access_import_export' => '允许导入和导出',
+ 'access_publish' => '允许发布帖子',
+ 'delete_confirm' => '你确定?',
+ 'chart_published' => '已发布',
+ 'chart_drafts' => '草稿',
+ 'chart_total' => '总数'
+ ],
+ 'posts' => [
+ 'list_title' => '管理博客帖子',
+ 'filter_category' => '分类',
+ 'filter_published' => '发布',
+ 'filter_date' => '日期',
+ 'new_post' => '创建帖子',
+ 'export_post' => '导出帖子',
+ 'import_post' => '导入帖子'
+ ],
+ 'post' => [
+ 'title' => '标题',
+ 'title_placeholder' => '新帖子标题',
+ 'content' => '内容',
+ 'content_html' => 'HTML 内容',
+ 'slug' => '别名',
+ 'slug_placeholder' => 'new-post-slug',
+ 'categories' => '分类',
+ 'author_email' => '作者邮箱',
+ 'created' => '创建时间',
+ 'created_date' => '创建日期',
+ 'updated' => '更新时间',
+ 'updated_date' => '更新日期',
+ 'published' => '发布时间',
+ 'published_date' => '发布日期',
+ 'published_validation' => '请指定发布日期',
+ 'tab_edit' => '编辑',
+ 'tab_categories' => '分类',
+ 'categories_comment' => '选择帖子属于那个分类',
+ 'categories_placeholder' => '没有分类,你应该先创建一个分类!',
+ 'tab_manage' => '管理',
+ 'published_on' => '发布于',
+ 'excerpt' => '摘录',
+ 'summary' => '总结',
+ 'featured_images' => '特色图片',
+ 'delete_confirm' => '确定删除该帖子?',
+ 'close_confirm' => '该帖子未保存.',
+ 'return_to_posts' => '返回帖子列表'
+ ],
+ 'categories' => [
+ 'list_title' => '管理博客分类',
+ 'new_category' => '新分类',
+ 'uncategorized' => '未分类'
+ ],
+ 'category' => [
+ 'name' => '名称',
+ 'name_placeholder' => '新分类名称',
+ 'description' => '描述',
+ 'slug' => '别名',
+ 'slug_placeholder' => 'new-category-slug',
+ 'posts' => '帖子',
+ 'delete_confirm' => '确定删除分类?',
+ 'return_to_categories' => '返回博客分类列表',
+ 'reorder' => '重新排序分类'
+ ],
+ 'menuitem' => [
+ 'blog_category' => '博客分类',
+ 'all_blog_categories' => '所有博客分类',
+ 'blog_post' => '博客帖子',
+ 'all_blog_posts' => '所有博客帖子'
+ ],
+ 'settings' => [
+ 'category_title' => '分类列表',
+ 'category_description' => '在页面上显示帖子分类列表.',
+ 'category_slug' => '分类别名',
+ 'category_slug_description' => "用分类别名查找博客分类. 该值被默认组件的partial用来激活当前分类.",
+ 'category_display_empty' => '显示空的分类',
+ 'category_display_empty_description' => '显示没有帖子的分类.',
+ 'category_page' => '分类页',
+ 'category_page_description' => '用来生成分类链接的分类页面文件名称. 该属性被默认组件partial所使用.',
+ 'post_title' => '帖子',
+ 'post_description' => '在页面上显示博客帖子.',
+ 'post_slug' => '帖子别名',
+ 'post_slug_description' => "用帖子别名查找博客帖子.",
+ 'post_category' => '分类页',
+ 'post_category_description' => '用来生成分类链接的分类页面文件名称. 该属性被默认组件partial所使用.',
+ 'posts_title' => '帖子列表',
+ 'posts_description' => '在页面上显示最近发布的博客帖子列表.',
+ 'posts_pagination' => '页数',
+ 'posts_pagination_description' => '该值用来判定用户所在页面.',
+ 'posts_filter' => '过滤分类',
+ 'posts_filter_description' => '输入分类的别名(slug)或者URL参数来过滤帖子. 留空显示所有帖子.',
+ 'posts_per_page' => '每页帖子数',
+ 'posts_per_page_validation' => '每一页帖子数量的值的格式错误',
+ 'posts_no_posts' => '没有帖子的消息',
+ 'posts_no_posts_description' => '如果博客帖子列表中一个帖子都没有要显示的提示消息. 该属性被默认组件partial所使用.',
+ 'posts_order' => '帖子排序',
+ 'posts_order_description' => '帖子排序的属性',
+ 'posts_category' => '分类页',
+ 'posts_category_description' => '用来生成"发布到"分类链接的分类页文件名称. 该属性被默认组件partial所使用.',
+ 'posts_post' => '帖子页',
+ 'posts_post_description' => ' 查看帖子的"详情"的页面文件. 该属性被默认组件partial所使用.',
+ 'posts_except_post' => '排除的帖子',
+ 'posts_except_post_description' => '输入帖子的ID/URL或者变量来排除你不想看见的帖子',
+ 'rssfeed_blog' => '博客页面',
+ 'rssfeed_blog_description' => '生成博客帖子首页面文件名称. 该属性被默认组件partial所使用.',
+ 'rssfeed_title' => 'RSS Feed',
+ 'rssfeed_description' => '从博客生成一个包含帖子的RSS Feed.'
+ ]
+];
diff --git a/plugins/rainlab/blog/models/Category.php b/plugins/rainlab/blog/models/Category.php
new file mode 100644
index 0000000..7f83355
--- /dev/null
+++ b/plugins/rainlab/blog/models/Category.php
@@ -0,0 +1,315 @@
+ 'required',
+ 'slug' => 'required|between:3,64|unique:rainlab_blog_categories',
+ 'code' => 'nullable|unique:rainlab_blog_categories',
+ ];
+
+ /**
+ * @var array Attributes that support translation, if available.
+ */
+ public $translatable = [
+ 'name',
+ 'description',
+ ['slug', 'index' => true]
+ ];
+
+ protected $guarded = [];
+
+ public $belongsToMany = [
+ 'posts' => ['RainLab\Blog\Models\Post',
+ 'table' => 'rainlab_blog_posts_categories',
+ 'order' => 'published_at desc',
+ 'scope' => 'isPublished'
+ ],
+ 'posts_count' => ['RainLab\Blog\Models\Post',
+ 'table' => 'rainlab_blog_posts_categories',
+ 'scope' => 'isPublished',
+ 'count' => true
+ ]
+ ];
+
+ public function beforeValidate()
+ {
+ // Generate a URL slug for this model
+ if (!$this->exists && !$this->slug) {
+ $this->slug = Str::slug($this->name);
+ }
+ }
+
+ public function afterDelete()
+ {
+ $this->posts()->detach();
+ }
+
+ public function getPostCountAttribute()
+ {
+ return optional($this->posts_count->first())->count ?? 0;
+ }
+
+ /**
+ * Count posts in this and nested categories
+ * @return int
+ */
+ public function getNestedPostCount()
+ {
+ return $this->post_count + $this->children->sum(function ($category) {
+ return $category->getNestedPostCount();
+ });
+ }
+
+ /**
+ * Sets the "url" attribute with a URL to this object
+ *
+ * @param string $pageName
+ * @param Cms\Classes\Controller $controller
+ *
+ * @return string
+ */
+ public function setUrl($pageName, $controller)
+ {
+ $params = [
+ 'id' => $this->id,
+ 'slug' => $this->slug
+ ];
+
+ return $this->url = $controller->pageUrl($pageName, $params, false);
+ }
+
+ /**
+ * Handler for the pages.menuitem.getTypeInfo event.
+ * Returns a menu item type information. The type information is returned as array
+ * with the following elements:
+ * - references - a list of the item type reference options. The options are returned in the
+ * ["key"] => "title" format for options that don't have sub-options, and in the format
+ * ["key"] => ["title"=>"Option title", "items"=>[...]] for options that have sub-options. Optional,
+ * required only if the menu item type requires references.
+ * - nesting - Boolean value indicating whether the item type supports nested items. Optional,
+ * false if omitted.
+ * - dynamicItems - Boolean value indicating whether the item type could generate new menu items.
+ * Optional, false if omitted.
+ * - cmsPages - a list of CMS pages (objects of the Cms\Classes\Page class), if the item type requires a CMS page reference to
+ * resolve the item URL.
+ * @param string $type Specifies the menu item type
+ * @return array Returns an array
+ */
+ public static function getMenuTypeInfo($type)
+ {
+ $result = [];
+
+ if ($type == 'blog-category') {
+ $result = [
+ 'references' => self::listSubCategoryOptions(),
+ 'nesting' => true,
+ 'dynamicItems' => true
+ ];
+ }
+
+ if ($type == 'all-blog-categories') {
+ $result = [
+ 'dynamicItems' => true
+ ];
+ }
+
+ if ($result) {
+ $theme = Theme::getActiveTheme();
+
+ $pages = CmsPage::listInTheme($theme, true);
+ $cmsPages = [];
+ foreach ($pages as $page) {
+ if (!$page->hasComponent('blogPosts')) {
+ continue;
+ }
+
+ /*
+ * Component must use a category filter with a routing parameter
+ * eg: categoryFilter = "{{ :somevalue }}"
+ */
+ $properties = $page->getComponentProperties('blogPosts');
+ if (!isset($properties['categoryFilter']) || !preg_match('/{{\s*:/', $properties['categoryFilter'])) {
+ continue;
+ }
+
+ $cmsPages[] = $page;
+ }
+
+ $result['cmsPages'] = $cmsPages;
+ }
+
+ return $result;
+ }
+
+ protected static function listSubCategoryOptions()
+ {
+ $category = self::getNested();
+
+ $iterator = function($categories) use (&$iterator) {
+ $result = [];
+
+ foreach ($categories as $category) {
+ if (!$category->children) {
+ $result[$category->id] = $category->name;
+ }
+ else {
+ $result[$category->id] = [
+ 'title' => $category->name,
+ 'items' => $iterator($category->children)
+ ];
+ }
+ }
+
+ return $result;
+ };
+
+ return $iterator($category);
+ }
+
+ /**
+ * Handler for the pages.menuitem.resolveItem event.
+ * Returns information about a menu item. The result is an array
+ * with the following keys:
+ * - url - the menu item URL. Not required for menu item types that return all available records.
+ * The URL should be returned relative to the website root and include the subdirectory, if any.
+ * Use the Url::to() helper to generate the URLs.
+ * - isActive - determines whether the menu item is active. Not required for menu item types that
+ * return all available records.
+ * - items - an array of arrays with the same keys (url, isActive, items) + the title key.
+ * The items array should be added only if the $item's $nesting property value is TRUE.
+ * @param \RainLab\Pages\Classes\MenuItem $item Specifies the menu item.
+ * @param \Cms\Classes\Theme $theme Specifies the current theme.
+ * @param string $url Specifies the current page URL, normalized, in lower case
+ * The URL is specified relative to the website root, it includes the subdirectory name, if any.
+ * @return mixed Returns an array. Returns null if the item cannot be resolved.
+ */
+ public static function resolveMenuItem($item, $url, $theme)
+ {
+ $result = null;
+
+ if ($item->type == 'blog-category') {
+ if (!$item->reference || !$item->cmsPage) {
+ return;
+ }
+
+ $category = self::find($item->reference);
+ if (!$category) {
+ return;
+ }
+
+ $pageUrl = self::getCategoryPageUrl($item->cmsPage, $category, $theme);
+ if (!$pageUrl) {
+ return;
+ }
+
+ $pageUrl = Url::to($pageUrl);
+
+ $result = [];
+ $result['url'] = $pageUrl;
+ $result['isActive'] = $pageUrl == $url;
+ $result['mtime'] = $category->updated_at;
+
+ if ($item->nesting) {
+ $iterator = function($categories) use (&$iterator, &$item, &$theme, $url) {
+ $branch = [];
+
+ foreach ($categories as $category) {
+
+ $branchItem = [];
+ $branchItem['url'] = self::getCategoryPageUrl($item->cmsPage, $category, $theme);
+ $branchItem['isActive'] = $branchItem['url'] == $url;
+ $branchItem['title'] = $category->name;
+ $branchItem['mtime'] = $category->updated_at;
+
+ if ($category->children) {
+ $branchItem['items'] = $iterator($category->children);
+ }
+
+ $branch[] = $branchItem;
+ }
+
+ return $branch;
+ };
+
+ $result['items'] = $iterator($category->children);
+ }
+ }
+ elseif ($item->type == 'all-blog-categories') {
+ $result = [
+ 'items' => []
+ ];
+
+ $categories = self::with('posts_count')->orderBy('name')->get();
+ foreach ($categories as $category) {
+ try {
+ $postCount = $category->posts_count->first()->count ?? null;
+ if ($postCount === 0) {
+ continue;
+ }
+ }
+ catch (\Exception $ex) {}
+
+ $categoryItem = [
+ 'title' => $category->name,
+ 'url' => self::getCategoryPageUrl($item->cmsPage, $category, $theme),
+ 'mtime' => $category->updated_at
+ ];
+
+ $categoryItem['isActive'] = $categoryItem['url'] == $url;
+
+ $result['items'][] = $categoryItem;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns URL of a category page.
+ *
+ * @param $pageCode
+ * @param $category
+ * @param $theme
+ */
+ protected static function getCategoryPageUrl($pageCode, $category, $theme)
+ {
+ $page = CmsPage::loadCached($theme, $pageCode);
+ if (!$page) {
+ return;
+ }
+
+ $properties = $page->getComponentProperties('blogPosts');
+ if (!isset($properties['categoryFilter'])) {
+ return;
+ }
+
+ /*
+ * Extract the routing parameter name from the category filter
+ * eg: {{ :someRouteParam }}
+ */
+ if (!preg_match('/^\{\{([^\}]+)\}\}$/', $properties['categoryFilter'], $matches)) {
+ return;
+ }
+
+ $paramName = substr(trim($matches[1]), 1);
+ $url = CmsPage::url($page->getBaseFileName(), [$paramName => $category->slug]);
+
+ return $url;
+ }
+}
diff --git a/plugins/rainlab/blog/models/Post.php b/plugins/rainlab/blog/models/Post.php
new file mode 100644
index 0000000..d41b41d
--- /dev/null
+++ b/plugins/rainlab/blog/models/Post.php
@@ -0,0 +1,726 @@
+ 'required',
+ 'slug' => ['required', 'regex:/^[a-z0-9\/\:_\-\*\[\]\+\?\|]*$/i', 'unique:rainlab_blog_posts'],
+ 'content' => 'required',
+ 'excerpt' => ''
+ ];
+
+ /**
+ * @var array Attributes that support translation, if available.
+ */
+ public $translatable = [
+ 'title',
+ 'content',
+ 'content_html',
+ 'excerpt',
+ 'metadata',
+ ['slug', 'index' => true]
+ ];
+
+ /**
+ * @var array Attributes to be stored as JSON
+ */
+ protected $jsonable = ['metadata'];
+
+ /**
+ * The attributes that should be mutated to dates.
+ * @var array
+ */
+ protected $dates = ['published_at'];
+
+ /**
+ * The attributes on which the post list can be ordered.
+ * @var array
+ */
+ public static $allowedSortingOptions = [
+ 'title asc' => 'rainlab.blog::lang.sorting.title_asc',
+ 'title desc' => 'rainlab.blog::lang.sorting.title_desc',
+ 'created_at asc' => 'rainlab.blog::lang.sorting.created_asc',
+ 'created_at desc' => 'rainlab.blog::lang.sorting.created_desc',
+ 'updated_at asc' => 'rainlab.blog::lang.sorting.updated_asc',
+ 'updated_at desc' => 'rainlab.blog::lang.sorting.updated_desc',
+ 'published_at asc' => 'rainlab.blog::lang.sorting.published_asc',
+ 'published_at desc' => 'rainlab.blog::lang.sorting.published_desc',
+ 'random' => 'rainlab.blog::lang.sorting.random'
+ ];
+
+ /*
+ * Relations
+ */
+ public $belongsTo = [
+ 'user' => BackendUser::class
+ ];
+
+ public $belongsToMany = [
+ 'categories' => [
+ Category::class,
+ 'table' => 'rainlab_blog_posts_categories',
+ 'order' => 'name'
+ ]
+ ];
+
+ public $attachMany = [
+ 'featured_images' => [\System\Models\File::class, 'order' => 'sort_order'],
+ 'content_images' => \System\Models\File::class
+ ];
+
+ /**
+ * @var array The accessors to append to the model's array form.
+ */
+ protected $appends = ['summary', 'has_summary'];
+
+ public $preview = null;
+
+ /**
+ * Limit visibility of the published-button
+ *
+ * @param $fields
+ * @param null $context
+ * @return void
+ */
+ public function filterFields($fields, $context = null)
+ {
+ if (!isset($fields->published, $fields->published_at)) {
+ return;
+ }
+
+ $user = BackendAuth::getUser();
+
+ if (!$user->hasAnyAccess(['rainlab.blog.access_publish'])) {
+ $fields->published->hidden = true;
+ $fields->published_at->hidden = true;
+ }
+ else {
+ $fields->published->hidden = false;
+ $fields->published_at->hidden = false;
+ }
+ }
+
+ public function afterValidate()
+ {
+ if ($this->published && !$this->published_at) {
+ throw new ValidationException([
+ 'published_at' => Lang::get('rainlab.blog::lang.post.published_validation')
+ ]);
+ }
+ }
+
+ public function getUserOptions()
+ {
+ $options = [];
+
+ foreach (BackendUser::all() as $user) {
+ $options[$user->id] = $user->fullname . ' ('.$user->login.')';
+ }
+
+ return $options;
+ }
+
+ public function beforeSave()
+ {
+ if (empty($this->user)) {
+ $user = BackendAuth::getUser();
+ if (!is_null($user)) {
+ $this->user = $user->id;
+ }
+ }
+ $this->content_html = self::formatHtml($this->content);
+ }
+
+ /**
+ * Sets the "url" attribute with a URL to this object.
+ * @param string $pageName
+ * @param Controller $controller
+ * @param array $params Override request URL parameters
+ *
+ * @return string
+ */
+ public function setUrl($pageName, $controller, $params = [])
+ {
+ $params = array_merge([
+ 'id' => $this->id,
+ 'slug' => $this->slug,
+ ], $params);
+
+ if (empty($params['category'])) {
+ $params['category'] = $this->categories->count() ? $this->categories->first()->slug : null;
+ }
+
+ // Expose published year, month and day as URL parameters.
+ if ($this->published) {
+ $params['year'] = $this->published_at->format('Y');
+ $params['month'] = $this->published_at->format('m');
+ $params['day'] = $this->published_at->format('d');
+ }
+
+ return $this->url = $controller->pageUrl($pageName, $params);
+ }
+
+ /**
+ * Used to test if a certain user has permission to edit post,
+ * returns TRUE if the user is the owner or has other posts access.
+ * @param BackendUser $user
+ * @return bool
+ */
+ public function canEdit(BackendUser $user)
+ {
+ return ($this->user_id == $user->id) || $user->hasAnyAccess(['rainlab.blog.access_other_posts']);
+ }
+
+ public static function formatHtml($input, $preview = false)
+ {
+ $result = Markdown::parse(trim($input));
+
+ // Check to see if the HTML should be cleaned from potential XSS
+ $user = BackendAuth::getUser();
+ if (!$user || !$user->hasAccess('backend.allow_unsafe_markdown')) {
+ $result = Html::clean($result);
+ }
+
+ if ($preview) {
+ $result = str_replace('
', '
', $result);
+ }
+
+ $result = TagProcessor::instance()->processTags($result, $preview);
+
+ return $result;
+ }
+
+ //
+ // Scopes
+ //
+
+ public function scopeIsPublished($query)
+ {
+ return $query
+ ->whereNotNull('published')
+ ->where('published', true)
+ ->whereNotNull('published_at')
+ ->where('published_at', '<', Carbon::now())
+ ;
+ }
+
+ /**
+ * Lists posts for the frontend
+ *
+ * @param $query
+ * @param array $options Display options
+ * @return Post
+ */
+ public function scopeListFrontEnd($query, $options)
+ {
+ /*
+ * Default options
+ */
+ extract(array_merge([
+ 'page' => 1,
+ 'perPage' => 30,
+ 'sort' => 'created_at',
+ 'categories' => null,
+ 'exceptCategories' => null,
+ 'category' => null,
+ 'search' => '',
+ 'published' => true,
+ 'exceptPost' => null
+ ], $options));
+
+ $searchableFields = ['title', 'slug', 'excerpt', 'content'];
+
+ if ($published) {
+ $query->isPublished();
+ }
+
+ /*
+ * Except post(s)
+ */
+ if ($exceptPost) {
+ $exceptPosts = (is_array($exceptPost)) ? $exceptPost : [$exceptPost];
+ $exceptPostIds = [];
+ $exceptPostSlugs = [];
+
+ foreach ($exceptPosts as $exceptPost) {
+ $exceptPost = trim($exceptPost);
+
+ if (is_numeric($exceptPost)) {
+ $exceptPostIds[] = $exceptPost;
+ } else {
+ $exceptPostSlugs[] = $exceptPost;
+ }
+ }
+
+ if (count($exceptPostIds)) {
+ $query->whereNotIn('id', $exceptPostIds);
+ }
+ if (count($exceptPostSlugs)) {
+ $query->whereNotIn('slug', $exceptPostSlugs);
+ }
+ }
+
+ /*
+ * Sorting
+ */
+ if (in_array($sort, array_keys(static::$allowedSortingOptions))) {
+ if ($sort == 'random') {
+ $query->inRandomOrder();
+ } else {
+ @list($sortField, $sortDirection) = explode(' ', $sort);
+
+ if (is_null($sortDirection)) {
+ $sortDirection = "desc";
+ }
+
+ $query->orderBy($sortField, $sortDirection);
+ }
+ }
+
+ /*
+ * Search
+ */
+ $search = trim($search);
+ if (strlen($search)) {
+ $query->searchWhere($search, $searchableFields);
+ }
+
+ /*
+ * Categories
+ */
+ if ($categories !== null) {
+ $categories = is_array($categories) ? $categories : [$categories];
+ $query->whereHas('categories', function($q) use ($categories) {
+ $q->withoutGlobalScope(NestedTreeScope::class)->whereIn('id', $categories);
+ });
+ }
+
+ /*
+ * Except Categories
+ */
+ if (!empty($exceptCategories)) {
+ $exceptCategories = is_array($exceptCategories) ? $exceptCategories : [$exceptCategories];
+ array_walk($exceptCategories, 'trim');
+
+ $query->whereDoesntHave('categories', function ($q) use ($exceptCategories) {
+ $q->withoutGlobalScope(NestedTreeScope::class)->whereIn('slug', $exceptCategories);
+ });
+ }
+
+ /*
+ * Category, including children
+ */
+ if ($category !== null) {
+ $category = Category::find($category);
+
+ $categories = $category->getAllChildrenAndSelf()->lists('id');
+ $query->whereHas('categories', function($q) use ($categories) {
+ $q->withoutGlobalScope(NestedTreeScope::class)->whereIn('id', $categories);
+ });
+ }
+
+ return $query->paginate($perPage, $page);
+ }
+
+ /**
+ * Allows filtering for specifc categories.
+ * @param Illuminate\Query\Builder $query QueryBuilder
+ * @param array $categories List of category ids
+ * @return Illuminate\Query\Builder QueryBuilder
+ */
+ public function scopeFilterCategories($query, $categories)
+ {
+ return $query->whereHas('categories', function($q) use ($categories) {
+ $q->withoutGlobalScope(NestedTreeScope::class)->whereIn('id', $categories);
+ });
+ }
+
+ public function scopeSearched($query, $locale, $queryString)
+ {
+ if ($locale == 'ru') {
+ $query = $query->where('title', 'like', "%$queryString%")
+ ->orWhere('content', 'like', "%$queryString%");
+ } else {
+ $query = $query->whereHas('translations', function ($innerQuery) use ($locale, $queryString) {
+ $innerQuery->where('locale', $locale)
+ ->where(function ($searchQuery) use ($queryString) {
+ $searchQuery->where('attribute_data->title', 'like', "%$queryString%")
+ ->orWhere('attribute_data->content', 'like', "%$queryString%");
+ });
+ });
+ }
+
+ return $query;
+ }
+
+ //
+ // Summary / Excerpt
+ //
+
+ /**
+ * Used by "has_summary", returns true if this post uses a summary (more tag).
+ * @return boolean
+ */
+ public function getHasSummaryAttribute()
+ {
+ $more = Config::get('rainlab.blog::summary_separator', '');
+ $length = Config::get('rainlab.blog::summary_default_length', 600);
+
+ return (
+ !!strlen(trim($this->excerpt)) ||
+ strpos($this->content_html, $more) !== false ||
+ strlen(Html::strip($this->content_html)) > $length
+ );
+ }
+
+ /**
+ * Used by "summary", if no excerpt is provided, generate one from the content.
+ * Returns the HTML content before the tag or a limited 600
+ * character version.
+ *
+ * @return string
+ */
+ public function getSummaryAttribute()
+ {
+ $excerpt = $this->excerpt;
+ if (strlen(trim($excerpt))) {
+ return $excerpt;
+ }
+
+ $more = Config::get('rainlab.blog::summary_separator', '');
+
+ if (strpos($this->content_html, $more) !== false) {
+ $parts = explode($more, $this->content_html);
+
+ return array_get($parts, 0);
+ }
+
+ $length = Config::get('rainlab.blog::summary_default_length', 600);
+
+ return Html::limit($this->content_html, $length);
+ }
+
+ //
+ // Next / Previous
+ //
+
+ /**
+ * Apply a constraint to the query to find the nearest sibling
+ *
+ * // Get the next post
+ * Post::applySibling()->first();
+ *
+ * // Get the previous post
+ * Post::applySibling(-1)->first();
+ *
+ * // Get the previous post, ordered by the ID attribute instead
+ * Post::applySibling(['direction' => -1, 'attribute' => 'id'])->first();
+ *
+ * @param $query
+ * @param array $options
+ * @return
+ */
+ public function scopeApplySibling($query, $options = [])
+ {
+ if (!is_array($options)) {
+ $options = ['direction' => $options];
+ }
+
+ extract(array_merge([
+ 'direction' => 'next',
+ 'attribute' => 'published_at'
+ ], $options));
+
+ $isPrevious = in_array($direction, ['previous', -1]);
+ $directionOrder = $isPrevious ? 'asc' : 'desc';
+ $directionOperator = $isPrevious ? '>' : '<';
+
+ $query->where('id', '<>', $this->id);
+
+ if (!is_null($this->$attribute)) {
+ $query->where($attribute, $directionOperator, $this->$attribute);
+ }
+
+ return $query->orderBy($attribute, $directionOrder);
+ }
+
+ /**
+ * Returns the next post, if available.
+ * @return self
+ */
+ public function nextPost()
+ {
+ return self::isPublished()->applySibling()->first();
+ }
+
+ /**
+ * Returns the previous post, if available.
+ * @return self
+ */
+ public function previousPost()
+ {
+ return self::isPublished()->applySibling(-1)->first();
+ }
+
+ //
+ // Menu helpers
+ //
+
+ /**
+ * Handler for the pages.menuitem.getTypeInfo event.
+ * Returns a menu item type information. The type information is returned as array
+ * with the following elements:
+ * - references - a list of the item type reference options. The options are returned in the
+ * ["key"] => "title" format for options that don't have sub-options, and in the format
+ * ["key"] => ["title"=>"Option title", "items"=>[...]] for options that have sub-options. Optional,
+ * required only if the menu item type requires references.
+ * - nesting - Boolean value indicating whether the item type supports nested items. Optional,
+ * false if omitted.
+ * - dynamicItems - Boolean value indicating whether the item type could generate new menu items.
+ * Optional, false if omitted.
+ * - cmsPages - a list of CMS pages (objects of the Cms\Classes\Page class), if the item type requires a CMS page reference to
+ * resolve the item URL.
+ *
+ * @param string $type Specifies the menu item type
+ * @return array Returns an array
+ */
+ public static function getMenuTypeInfo($type)
+ {
+ $result = [];
+
+ if ($type == 'blog-post') {
+ $references = [];
+
+ $posts = self::orderBy('title')->get();
+ foreach ($posts as $post) {
+ $references[$post->id] = $post->title;
+ }
+
+ $result = [
+ 'references' => $references,
+ 'nesting' => false,
+ 'dynamicItems' => false
+ ];
+ }
+
+ if ($type == 'all-blog-posts') {
+ $result = [
+ 'dynamicItems' => true
+ ];
+ }
+
+ if ($type == 'category-blog-posts') {
+ $references = [];
+
+ $categories = Category::orderBy('name')->get();
+ foreach ($categories as $category) {
+ $references[$category->id] = $category->name;
+ }
+
+ $result = [
+ 'references' => $references,
+ 'dynamicItems' => true
+ ];
+ }
+
+ if ($result) {
+ $theme = Theme::getActiveTheme();
+
+ $pages = CmsPage::listInTheme($theme, true);
+ $cmsPages = [];
+
+ foreach ($pages as $page) {
+ if (!$page->hasComponent('blogPost')) {
+ continue;
+ }
+
+ /*
+ * Component must use a categoryPage filter with a routing parameter and post slug
+ * eg: categoryPage = "{{ :somevalue }}", slug = "{{ :somevalue }}"
+ */
+ $properties = $page->getComponentProperties('blogPost');
+ if (!isset($properties['categoryPage']) || !preg_match('/{{\s*:/', $properties['slug'])) {
+ continue;
+ }
+
+ $cmsPages[] = $page;
+ }
+
+ $result['cmsPages'] = $cmsPages;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Handler for the pages.menuitem.resolveItem event.
+ * Returns information about a menu item. The result is an array
+ * with the following keys:
+ * - url - the menu item URL. Not required for menu item types that return all available records.
+ * The URL should be returned relative to the website root and include the subdirectory, if any.
+ * Use the Url::to() helper to generate the URLs.
+ * - isActive - determines whether the menu item is active. Not required for menu item types that
+ * return all available records.
+ * - items - an array of arrays with the same keys (url, isActive, items) + the title key.
+ * The items array should be added only if the $item's $nesting property value is TRUE.
+ *
+ * @param \RainLab\Pages\Classes\MenuItem $item Specifies the menu item.
+ * @param \Cms\Classes\Theme $theme Specifies the current theme.
+ * @param string $url Specifies the current page URL, normalized, in lower case
+ * The URL is specified relative to the website root, it includes the subdirectory name, if any.
+ * @return mixed Returns an array. Returns null if the item cannot be resolved.
+ */
+ public static function resolveMenuItem($item, $url, $theme)
+ {
+ $result = null;
+
+ if ($item->type == 'blog-post') {
+ if (!$item->reference || !$item->cmsPage) {
+ return;
+ }
+
+ $category = self::find($item->reference);
+ if (!$category) {
+ return;
+ }
+
+ $pageUrl = self::getPostPageUrl($item->cmsPage, $category, $theme);
+ if (!$pageUrl) {
+ return;
+ }
+
+ $pageUrl = Url::to($pageUrl);
+
+ $result = [];
+ $result['url'] = $pageUrl;
+ $result['isActive'] = $pageUrl == $url;
+ $result['mtime'] = $category->updated_at;
+ }
+ elseif ($item->type == 'all-blog-posts') {
+ $result = [
+ 'items' => []
+ ];
+
+ $posts = self::isPublished()
+ ->orderBy('title')
+ ->get()
+ ;
+
+ foreach ($posts as $post) {
+ $postItem = [
+ 'title' => $post->title,
+ 'url' => self::getPostPageUrl($item->cmsPage, $post, $theme),
+ 'mtime' => $post->updated_at
+ ];
+
+ $postItem['isActive'] = $postItem['url'] == $url;
+
+ $result['items'][] = $postItem;
+ }
+ }
+ elseif ($item->type == 'category-blog-posts') {
+ if (!$item->reference || !$item->cmsPage) {
+ return;
+ }
+
+ $category = Category::find($item->reference);
+ if (!$category) {
+ return;
+ }
+
+ $result = [
+ 'items' => []
+ ];
+
+ $query = self::isPublished()
+ ->orderBy('title');
+
+ $categories = $category->getAllChildrenAndSelf()->lists('id');
+ $query->whereHas('categories', function($q) use ($categories) {
+ $q->withoutGlobalScope(NestedTreeScope::class)->whereIn('id', $categories);
+ });
+
+ $posts = $query->get();
+
+ foreach ($posts as $post) {
+ $postItem = [
+ 'title' => $post->title,
+ 'url' => self::getPostPageUrl($item->cmsPage, $post, $theme),
+ 'mtime' => $post->updated_at
+ ];
+
+ $postItem['isActive'] = $postItem['url'] == $url;
+
+ $result['items'][] = $postItem;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns URL of a post page.
+ *
+ * @param $pageCode
+ * @param $category
+ * @param $theme
+ */
+ protected static function getPostPageUrl($pageCode, $category, $theme)
+ {
+ $page = CmsPage::loadCached($theme, $pageCode);
+ if (!$page) {
+ return;
+ }
+
+ $properties = $page->getComponentProperties('blogPost');
+ if (!isset($properties['slug'])) {
+ return;
+ }
+
+ /*
+ * Extract the routing parameter name from the category filter
+ * eg: {{ :someRouteParam }}
+ */
+ if (!preg_match('/^\{\{([^\}]+)\}\}$/', $properties['slug'], $matches)) {
+ return;
+ }
+
+ $paramName = substr(trim($matches[1]), 1);
+ $params = [
+ $paramName => $category->slug,
+ 'year' => $category->published_at->format('Y'),
+ 'month' => $category->published_at->format('m'),
+ 'day' => $category->published_at->format('d')
+ ];
+ $url = CmsPage::url($page->getBaseFileName(), $params);
+
+ return $url;
+ }
+}
diff --git a/plugins/rainlab/blog/models/PostExport.php b/plugins/rainlab/blog/models/PostExport.php
new file mode 100644
index 0000000..024d94a
--- /dev/null
+++ b/plugins/rainlab/blog/models/PostExport.php
@@ -0,0 +1,94 @@
+ [
+ 'Backend\Models\User',
+ 'key' => 'user_id'
+ ]
+ ];
+
+ public $belongsToMany = [
+ 'post_categories' => [
+ 'RainLab\Blog\Models\Category',
+ 'table' => 'rainlab_blog_posts_categories',
+ 'key' => 'post_id',
+ 'otherKey' => 'category_id'
+ ]
+ ];
+
+ public $hasMany = [
+ 'featured_images' => [
+ 'System\Models\File',
+ 'order' => 'sort_order',
+ 'key' => 'attachment_id',
+ 'conditions' => "field = 'featured_images' AND attachment_type = 'RainLab\\\\Blog\\\\Models\\\\Post'"
+ ]
+ ];
+
+ /**
+ * The accessors to append to the model's array form.
+ * @var array
+ */
+ protected $appends = [
+ 'author_email',
+ 'categories',
+ 'featured_image_urls'
+ ];
+
+ public function exportData($columns, $sessionKey = null)
+ {
+ $result = self::make()
+ ->with([
+ 'post_user',
+ 'post_categories',
+ 'featured_images'
+ ])
+ ->get()
+ ->toArray()
+ ;
+
+ return $result;
+ }
+
+ public function getAuthorEmailAttribute()
+ {
+ if (!$this->post_user) {
+ return '';
+ }
+
+ return $this->post_user->email;
+ }
+
+ public function getCategoriesAttribute()
+ {
+ if (!$this->post_categories) {
+ return '';
+ }
+
+ return $this->encodeArrayValue($this->post_categories->lists('name'));
+ }
+
+ public function getFeaturedImageUrlsAttribute()
+ {
+ if (!$this->featured_images) {
+ return '';
+ }
+
+ return $this->encodeArrayValue($this->featured_images->map(function ($image) {
+ return $image->getPath();
+ }));
+ }
+}
diff --git a/plugins/rainlab/blog/models/PostImport.php b/plugins/rainlab/blog/models/PostImport.php
new file mode 100644
index 0000000..858d9f0
--- /dev/null
+++ b/plugins/rainlab/blog/models/PostImport.php
@@ -0,0 +1,155 @@
+ 'required',
+ 'content' => 'required'
+ ];
+
+ protected $authorEmailCache = [];
+
+ protected $categoryNameCache = [];
+
+ public function getDefaultAuthorOptions()
+ {
+ return AuthorModel::all()->lists('full_name', 'email');
+ }
+
+ public function getCategoriesOptions()
+ {
+ return Category::lists('name', 'id');
+ }
+
+ public function importData($results, $sessionKey = null)
+ {
+ $firstRow = reset($results);
+
+ // Validation
+ if ($this->auto_create_categories && !array_key_exists('categories', $firstRow)) {
+ throw new ApplicationException('Please specify a match for the Categories column.');
+ }
+
+ // Import
+ foreach ($results as $row => $data) {
+ try {
+
+ if (!$title = array_get($data, 'title')) {
+ $this->logSkipped($row, 'Missing post title');
+ continue;
+ }
+
+ // Find or create
+ $post = Post::make();
+
+ if ($this->update_existing) {
+ $post = $this->findDuplicatePost($data) ?: $post;
+ }
+
+ $postExists = $post->exists;
+
+ // Set attributes
+ $except = ['id', 'categories', 'author_email'];
+
+ foreach (array_except($data, $except) as $attribute => $value) {
+ if (in_array($attribute, $post->getDates()) && empty($value)) {
+ continue;
+ }
+ $post->{$attribute} = isset($value) ? $value : null;
+ }
+
+ if ($author = $this->findAuthorFromEmail($data)) {
+ $post->user_id = $author->id;
+ }
+
+ $post->forceSave();
+
+ if ($categoryIds = $this->getCategoryIdsForPost($data)) {
+ $post->categories()->sync($categoryIds, false);
+ }
+
+ // Log results
+ if ($postExists) {
+ $this->logUpdated();
+ }
+ else {
+ $this->logCreated();
+ }
+ }
+ catch (Exception $ex) {
+ $this->logError($row, $ex->getMessage());
+ }
+ }
+ }
+
+ protected function findAuthorFromEmail($data)
+ {
+ if (!$email = array_get($data, 'email', $this->default_author)) {
+ return null;
+ }
+
+ if (isset($this->authorEmailCache[$email])) {
+ return $this->authorEmailCache[$email];
+ }
+
+ $author = AuthorModel::where('email', $email)->first();
+ return $this->authorEmailCache[$email] = $author;
+ }
+
+ protected function findDuplicatePost($data)
+ {
+ if ($id = array_get($data, 'id')) {
+ return Post::find($id);
+ }
+
+ $title = array_get($data, 'title');
+ $post = Post::where('title', $title);
+
+ if ($slug = array_get($data, 'slug')) {
+ $post->orWhere('slug', $slug);
+ }
+
+ return $post->first();
+ }
+
+ protected function getCategoryIdsForPost($data)
+ {
+ $ids = [];
+
+ if ($this->auto_create_categories) {
+ $categoryNames = $this->decodeArrayValue(array_get($data, 'categories'));
+
+ foreach ($categoryNames as $name) {
+ if (!$name = trim($name)) {
+ continue;
+ }
+
+ if (isset($this->categoryNameCache[$name])) {
+ $ids[] = $this->categoryNameCache[$name];
+ }
+ else {
+ $newCategory = Category::firstOrCreate(['name' => $name]);
+ $ids[] = $this->categoryNameCache[$name] = $newCategory->id;
+ }
+ }
+ }
+ elseif ($this->categories) {
+ $ids = (array) $this->categories;
+ }
+
+ return $ids;
+ }
+}
diff --git a/plugins/rainlab/blog/models/Settings.php b/plugins/rainlab/blog/models/Settings.php
new file mode 100644
index 0000000..2c27a53
--- /dev/null
+++ b/plugins/rainlab/blog/models/Settings.php
@@ -0,0 +1,18 @@
+ ['boolean'],
+ ];
+}
diff --git a/plugins/rainlab/blog/models/category/columns.yaml b/plugins/rainlab/blog/models/category/columns.yaml
new file mode 100644
index 0000000..578d846
--- /dev/null
+++ b/plugins/rainlab/blog/models/category/columns.yaml
@@ -0,0 +1,13 @@
+# ===================================
+# Column Definitions
+# ===================================
+
+columns:
+
+ name:
+ label: rainlab.blog::lang.category.name
+ searchable: true
+
+ post_count:
+ label: rainlab.blog::lang.category.posts
+ sortable: false
diff --git a/plugins/rainlab/blog/models/category/fields.yaml b/plugins/rainlab/blog/models/category/fields.yaml
new file mode 100644
index 0000000..adcf1bc
--- /dev/null
+++ b/plugins/rainlab/blog/models/category/fields.yaml
@@ -0,0 +1,23 @@
+# ===================================
+# Field Definitions
+# ===================================
+
+fields:
+
+ name:
+ label: rainlab.blog::lang.category.name
+ placeholder: rainlab.blog::lang.category.name_placeholder
+ span: left
+
+ slug:
+ label: rainlab.blog::lang.category.slug
+ span: right
+ placeholder: rainlab.blog::lang.category.slug_placeholder
+ preset: name
+
+ description:
+ label: 'rainlab.blog::lang.category.description'
+ size: large
+ oc.commentPosition: ''
+ span: full
+ type: textarea
diff --git a/plugins/rainlab/blog/models/post/columns.yaml b/plugins/rainlab/blog/models/post/columns.yaml
new file mode 100644
index 0000000..6567fcc
--- /dev/null
+++ b/plugins/rainlab/blog/models/post/columns.yaml
@@ -0,0 +1,36 @@
+# ===================================
+# Column Definitions
+# ===================================
+
+columns:
+
+ title:
+ label: rainlab.blog::lang.post.title
+ searchable: true
+
+ # author:
+ # label: Author
+ # relation: user
+ # select: login
+ # searchable: true
+
+ categories:
+ label: rainlab.blog::lang.post.categories
+ relation: categories
+ select: name
+ searchable: true
+ sortable: false
+
+ created_at:
+ label: rainlab.blog::lang.post.created
+ type: date
+ invisible: true
+
+ updated_at:
+ label: rainlab.blog::lang.post.updated
+ type: date
+ invisible: true
+
+ published_at:
+ label: rainlab.blog::lang.post.published
+ type: date
diff --git a/plugins/rainlab/blog/models/post/fields.yaml b/plugins/rainlab/blog/models/post/fields.yaml
new file mode 100644
index 0000000..ed7f590
--- /dev/null
+++ b/plugins/rainlab/blog/models/post/fields.yaml
@@ -0,0 +1,77 @@
+# ===================================
+# Field Definitions
+# ===================================
+
+fields:
+ title:
+ label: rainlab.blog::lang.post.title
+ span: left
+ placeholder: rainlab.blog::lang.post.title_placeholder
+
+ slug:
+ label: rainlab.blog::lang.post.slug
+ span: right
+ placeholder: rainlab.blog::lang.post.slug_placeholder
+ preset:
+ field: title
+ type: slug
+
+ toolbar:
+ type: partial
+ path: post_toolbar
+ cssClass: collapse-visible
+
+secondaryTabs:
+ stretch: true
+ fields:
+ content:
+ tab: rainlab.blog::lang.post.tab_edit
+ type: RainLab\Blog\FormWidgets\BlogMarkdown
+ cssClass: field-slim blog-post-preview
+ stretch: true
+ mode: split
+
+ categories:
+ tab: rainlab.blog::lang.post.tab_categories
+ type: relation
+ commentAbove: rainlab.blog::lang.post.categories_comment
+ placeholder: rainlab.blog::lang.post.categories_placeholder
+
+ published:
+ tab: rainlab.blog::lang.post.tab_manage
+ label: rainlab.blog::lang.post.published
+ span: left
+ type: checkbox
+
+ user:
+ tab: rainlab.blog::lang.post.tab_manage
+ label: rainlab.blog::lang.post.published_by
+ span: right
+ type: dropdown
+ emptyOption: rainlab.blog::lang.post.current_user
+
+ published_at:
+ tab: rainlab.blog::lang.post.tab_manage
+ label: rainlab.blog::lang.post.published_on
+ span: left
+ cssClass: checkbox-align
+ type: datepicker
+ mode: datetime
+ trigger:
+ action: enable
+ field: published
+ condition: checked
+
+ excerpt:
+ tab: rainlab.blog::lang.post.tab_manage
+ label: rainlab.blog::lang.post.excerpt
+ type: textarea
+ size: small
+
+ featured_images:
+ tab: rainlab.blog::lang.post.tab_manage
+ label: rainlab.blog::lang.post.featured_images
+ type: fileupload
+ mode: image
+ imageWidth: 200
+ imageHeight: 200
diff --git a/plugins/rainlab/blog/models/post/scopes.yaml b/plugins/rainlab/blog/models/post/scopes.yaml
new file mode 100644
index 0000000..6ddfc4c
--- /dev/null
+++ b/plugins/rainlab/blog/models/post/scopes.yaml
@@ -0,0 +1,43 @@
+# ===================================
+# Filter Scope Definitions
+# ===================================
+
+scopes:
+
+ category:
+
+ # Filter name
+ label: rainlab.blog::lang.posts.filter_category
+
+ # Model Class name
+ modelClass: RainLab\Blog\Models\Category
+
+ # Model attribute to display for the name
+ nameFrom: name
+
+ # Apply query scope
+ scope: FilterCategories
+
+ published:
+
+ # Filter name
+ label: rainlab.blog::lang.posts.filter_published
+
+ # Filter type
+ type: switch
+
+ # SQL Conditions
+ conditions:
+ - published <> '1'
+ - published = '1'
+
+ created_at:
+
+ # Filter name
+ label: rainlab.blog::lang.posts.filter_date
+
+ # Filter type
+ type: daterange
+
+ # SQL Conditions
+ conditions: created_at >= ':after' AND created_at <= ':before'
diff --git a/plugins/rainlab/blog/models/postexport/columns.yaml b/plugins/rainlab/blog/models/postexport/columns.yaml
new file mode 100644
index 0000000..b500a8f
--- /dev/null
+++ b/plugins/rainlab/blog/models/postexport/columns.yaml
@@ -0,0 +1,19 @@
+# ===================================
+# Column Definitions
+# ===================================
+
+columns:
+
+ id: ID
+ title: rainlab.blog::lang.post.title
+ content: rainlab.blog::lang.post.content
+ content_html: rainlab.blog::lang.post.content_html
+ excerpt: rainlab.blog::lang.post.excerpt
+ slug: rainlab.blog::lang.post.slug
+ categories: rainlab.blog::lang.post.categories
+ author_email: rainlab.blog::lang.post.author_email
+ featured_image_urls: rainlab.blog::lang.post.featured_images
+ created_at: rainlab.blog::lang.post.created_date
+ updated_at: rainlab.blog::lang.post.updated_date
+ published: rainlab.blog::lang.post.published
+ published_at: rainlab.blog::lang.post.published_date
diff --git a/plugins/rainlab/blog/models/postimport/columns.yaml b/plugins/rainlab/blog/models/postimport/columns.yaml
new file mode 100644
index 0000000..a2584ea
--- /dev/null
+++ b/plugins/rainlab/blog/models/postimport/columns.yaml
@@ -0,0 +1,17 @@
+# ===================================
+# Column Definitions
+# ===================================
+
+columns:
+
+ id: ID
+ title: rainlab.blog::lang.post.title
+ content: rainlab.blog::lang.post.content
+ excerpt: rainlab.blog::lang.post.excerpt
+ slug: rainlab.blog::lang.post.slug
+ categories: rainlab.blog::lang.post.categories
+ author_email: rainlab.blog::lang.post.author_email
+ created_at: rainlab.blog::lang.post.created_date
+ updated_at: rainlab.blog::lang.post.updated_date
+ published: rainlab.blog::lang.post.published
+ published_at: rainlab.blog::lang.post.published_date
diff --git a/plugins/rainlab/blog/models/postimport/fields.yaml b/plugins/rainlab/blog/models/postimport/fields.yaml
new file mode 100644
index 0000000..3e7600a
--- /dev/null
+++ b/plugins/rainlab/blog/models/postimport/fields.yaml
@@ -0,0 +1,37 @@
+# ===================================
+# Form Field Definitions
+# ===================================
+
+fields:
+
+ update_existing:
+ label: rainlab.blog::lang.import.update_existing_label
+ comment: rainlab.blog::lang.import.update_existing_comment
+ type: checkbox
+ default: true
+ span: left
+
+ auto_create_categories:
+ label: rainlab.blog::lang.import.auto_create_categories_label
+ comment: rainlab.blog::lang.import.auto_create_categories_comment
+ type: checkbox
+ default: true
+ span: right
+
+ categories:
+ label: rainlab.blog::lang.import.categories_label
+ commentAbove: rainlab.blog::lang.import.categories_comment
+ type: checkboxlist
+ span: right
+ cssClass: field-indent
+ trigger:
+ action: hide
+ field: auto_create_categories
+ condition: checked
+
+ default_author:
+ label: rainlab.blog::lang.import.default_author_label
+ comment: rainlab.blog::lang.import.default_author_comment
+ type: dropdown
+ placeholder: rainlab.blog::lang.import.default_author_placeholder
+ span: left
diff --git a/plugins/rainlab/blog/models/settings/fields.yaml b/plugins/rainlab/blog/models/settings/fields.yaml
new file mode 100644
index 0000000..3138a59
--- /dev/null
+++ b/plugins/rainlab/blog/models/settings/fields.yaml
@@ -0,0 +1,17 @@
+tabs:
+ fields:
+ show_all_posts:
+ span: auto
+ label: rainlab.blog::lang.blog.show_all_posts_label
+ comment: rainlab.blog::lang.blog.show_all_posts_comment
+ type: switch
+ default: 1
+ tab: rainlab.blog::lang.blog.tab_general
+
+ use_legacy_editor:
+ span: auto
+ label: rainlab.blog::lang.blog.use_legacy_editor_label
+ comment: rainlab.blog::lang.blog.use_legacy_editor_comment
+ type: switch
+ default: 0
+ tab: rainlab.blog::lang.blog.tab_general
diff --git a/plugins/rainlab/blog/updates/categories_add_nested_fields.php b/plugins/rainlab/blog/updates/categories_add_nested_fields.php
new file mode 100644
index 0000000..c5a2bd2
--- /dev/null
+++ b/plugins/rainlab/blog/updates/categories_add_nested_fields.php
@@ -0,0 +1,34 @@
+integer('parent_id')->unsigned()->index()->nullable();
+ $table->integer('nest_left')->nullable();
+ $table->integer('nest_right')->nullable();
+ $table->integer('nest_depth')->nullable();
+ });
+
+ foreach (CategoryModel::all() as $category) {
+ $category->setDefaultLeftAndRight();
+ $category->save();
+ }
+ }
+
+ public function down()
+ {
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/rainlab/blog/updates/create_categories_table.php b/plugins/rainlab/blog/updates/create_categories_table.php
new file mode 100644
index 0000000..988a9d4
--- /dev/null
+++ b/plugins/rainlab/blog/updates/create_categories_table.php
@@ -0,0 +1,41 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('name')->nullable();
+ $table->string('slug')->nullable()->index();
+ $table->string('code')->nullable();
+ $table->text('description')->nullable();
+ $table->integer('parent_id')->unsigned()->index()->nullable();
+ $table->integer('nest_left')->nullable();
+ $table->integer('nest_right')->nullable();
+ $table->integer('nest_depth')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('rainlab_blog_posts_categories', function($table)
+ {
+ $table->engine = 'InnoDB';
+ $table->integer('post_id')->unsigned();
+ $table->integer('category_id')->unsigned();
+ $table->primary(['post_id', 'category_id']);
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_blog_categories');
+ Schema::dropIfExists('rainlab_blog_posts_categories');
+ }
+
+}
diff --git a/plugins/rainlab/blog/updates/create_posts_table.php b/plugins/rainlab/blog/updates/create_posts_table.php
new file mode 100644
index 0000000..8ec081b
--- /dev/null
+++ b/plugins/rainlab/blog/updates/create_posts_table.php
@@ -0,0 +1,32 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->integer('user_id')->unsigned()->nullable()->index();
+ $table->string('title')->nullable();
+ $table->string('slug')->index();
+ $table->text('excerpt')->nullable();
+ $table->longText('content')->nullable();
+ $table->longText('content_html')->nullable();
+ $table->timestamp('published_at')->nullable();
+ $table->boolean('published')->default(false);
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_blog_posts');
+ }
+
+}
diff --git a/plugins/rainlab/blog/updates/posts_add_metadata.php b/plugins/rainlab/blog/updates/posts_add_metadata.php
new file mode 100644
index 0000000..ccd96bb
--- /dev/null
+++ b/plugins/rainlab/blog/updates/posts_add_metadata.php
@@ -0,0 +1,31 @@
+mediumText('metadata')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ if (Schema::hasColumn('rainlab_blog_posts', 'metadata')) {
+ Schema::table('rainlab_blog_posts', function ($table) {
+ $table->dropColumn('metadata');
+ });
+ }
+ }
+
+}
diff --git a/plugins/rainlab/blog/updates/seed_all_tables.php b/plugins/rainlab/blog/updates/seed_all_tables.php
new file mode 100644
index 0000000..7be9344
--- /dev/null
+++ b/plugins/rainlab/blog/updates/seed_all_tables.php
@@ -0,0 +1,34 @@
+ 'First blog post',
+ 'slug' => 'first-blog-post',
+ 'content' => '
+This is your first ever **blog post**! It might be a good idea to update this post with some more relevant content.
+
+You can edit this content by selecting **Blog** from the administration back-end menu.
+
+*Enjoy the good times!*
+ ',
+ 'excerpt' => 'The first ever blog post is here. It might be a good idea to update this post with some more relevant content.',
+ 'published_at' => Carbon::now(),
+ 'published' => true
+ ]);
+
+ Category::create([
+ 'name' => trans('rainlab.blog::lang.categories.uncategorized'),
+ 'slug' => 'uncategorized',
+ ]);
+ }
+
+}
diff --git a/plugins/rainlab/blog/updates/update_timestamp_nullable.php b/plugins/rainlab/blog/updates/update_timestamp_nullable.php
new file mode 100644
index 0000000..7de7961
--- /dev/null
+++ b/plugins/rainlab/blog/updates/update_timestamp_nullable.php
@@ -0,0 +1,20 @@
+ "Builder",
+ 'description' => "Provides visual tools for building October plugins.",
+ 'author' => 'Alexey Bobkov, Samuel Georges',
+ 'icon' => 'icon-wrench',
+ 'homepage' => 'https://github.com/rainlab/builder-plugin'
+ ];
+ }
+
+ /**
+ * registerComponents
+ */
+ public function registerComponents()
+ {
+ return [
+ \RainLab\Builder\Components\RecordList::class => 'builderList',
+ \RainLab\Builder\Components\RecordDetails::class => 'builderDetails'
+ ];
+ }
+
+ /**
+ * registerPermissions
+ */
+ public function registerPermissions()
+ {
+ return [
+ 'rainlab.builder.manage_plugins' => [
+ 'tab' => "Builder",
+ 'label' => 'rainlab.builder::lang.plugin.manage_plugins'
+ ]
+ ];
+ }
+
+ /**
+ * registerNavigation
+ */
+ public function registerNavigation()
+ {
+ return [
+ 'builder' => [
+ 'label' => "Builder",
+ 'url' => Backend::url('rainlab/builder'),
+ 'icon' => 'icon-wrench',
+ 'iconSvg' => 'plugins/rainlab/builder/assets/images/builder-icon.svg',
+ 'permissions' => ['rainlab.builder.manage_plugins'],
+ 'order' => 400,
+ 'useDropdown' => false,
+
+ 'sideMenu' => [
+ 'database' => [
+ 'label' => 'rainlab.builder::lang.database.menu_label',
+ 'icon' => 'icon-hdd-o',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'database'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'models' => [
+ 'label' => 'rainlab.builder::lang.model.menu_label',
+ 'icon' => 'icon-random',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'models'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'permissions' => [
+ 'label' => 'rainlab.builder::lang.permission.menu_label',
+ 'icon' => 'icon-unlock-alt',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-no-side-panel' => 'true', 'data-builder-command' => 'permission:cmdOpenPermissions', 'data-menu-item' => 'permissions'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'menus' => [
+ 'label' => 'rainlab.builder::lang.menu.menu_label',
+ 'icon' => 'icon-location-arrow',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-no-side-panel' => 'true', 'data-builder-command' => 'menus:cmdOpenMenus', 'data-menu-item' => 'menus'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'controllers' => [
+ 'label' => 'rainlab.builder::lang.controller.menu_label',
+ 'icon' => 'icon-asterisk',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'controllers'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'versions' => [
+ 'label' => 'rainlab.builder::lang.version.menu_label',
+ 'icon' => 'icon-code-fork',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'version'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'localization' => [
+ 'label' => 'rainlab.builder::lang.localization.menu_label',
+ 'icon' => 'icon-globe',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'localization'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'code' => [
+ 'label' => 'Code',
+ 'icon' => 'icon-file-code-o',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-menu-item' => 'code'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ],
+ 'imports' => [
+ 'label' => 'Import',
+ 'icon' => 'icon-arrow-circle-down',
+ 'url' => 'javascript:;',
+ 'attributes' => ['data-no-side-panel' => 'true', 'data-builder-command' => 'imports:cmdOpenImports', 'data-menu-item' => 'imports'],
+ 'permissions' => ['rainlab.builder.manage_plugins']
+ ]
+ ]
+
+ ]
+ ];
+ }
+
+ /**
+ * registerSettings
+ */
+ public function registerSettings()
+ {
+ return [
+ 'config' => [
+ 'label' => 'Builder',
+ 'icon' => 'icon-wrench',
+ 'description' => 'Set your author name and namespace for plugin creation.',
+ 'class' => 'RainLab\Builder\Models\Settings',
+ 'permissions' => ['rainlab.builder.manage_plugins'],
+ 'order' => 600
+ ]
+ ];
+ }
+
+ /**
+ * boot
+ */
+ public function boot()
+ {
+ Event::listen('pages.builder.registerControls', function ($controlLibrary) {
+ new StandardControlsRegistry($controlLibrary);
+ });
+
+ Event::listen('pages.builder.registerControllerBehaviors', function ($behaviorLibrary) {
+ new StandardBehaviorsRegistry($behaviorLibrary);
+ });
+
+ Event::listen('pages.builder.registerTailorBlueprints', function ($blueprintLibrary) {
+ new StandardBlueprintsRegistry($blueprintLibrary);
+ });
+
+ // Register reserved keyword validation
+ Event::listen('translator.beforeResolve', function ($key, $replaces, $locale) {
+ if ($key === 'validation.reserved') {
+ return Lang::get('rainlab.builder::lang.validation.reserved');
+ }
+ });
+
+ $this->callAfterResolving('validator', function ($validator) {
+ $validator->extend('reserved', Reserved::class);
+ $validator->replacer('reserved', function ($message, $attribute, $rule, $parameters) {
+ // Fixes lowercase attribute names in the new plugin modal form
+ return ucfirst($message);
+ });
+ });
+
+ // Register doctrine types
+ if (!DoctrineType::hasType('timestamp')) {
+ DoctrineType::addType('timestamp', \RainLab\Builder\Classes\Doctrine\TimestampType::class);
+ }
+ }
+}
diff --git a/plugins/rainlab/builder/README.md b/plugins/rainlab/builder/README.md
new file mode 100644
index 0000000..7596674
--- /dev/null
+++ b/plugins/rainlab/builder/README.md
@@ -0,0 +1,391 @@
+Builder is a visual development tool. It shortens plugin development time by automating common development tasks and makes programming fun again. With Builder you can create a fully functional plugin scaffold in a matter of minutes.
+
+Builder makes the learning curve less steep by providing a visual interface that naturally incorporates October's design patterns and documentation. Here’s an example, instead of looking into the documentation for a list of supported form controls and their features, you can just open the Form Builder, find a suitable control in the Control Palette, add the control to the form and explore its properties with the visual inspector.
+
+Builder implements a Rapid Application Development process that automates the boring activities without sacrificing complete control. With this tool you can spend more time implementing the plugin's business logic in your favorite code editor rather than dealing with the more mundane tasks, like building forms or managing plugin versions.
+
+Plugins created with the help of Builder are no different to plugins that you would usually create by hand. That means that you can continue to use your usual “hands on” workflow for updating your servers, managing the code versions and sharing work with your teammates.
+
+**What's New!** Builder has been revamped to support October CMS v3, including dark mode, updated specifications, a Tailor import tool, and a new inline code editor.
+
+## Requirements
+
+- October CMS 3.3 or above
+
+### Installation
+
+Run the following to install this plugin:
+
+```bash
+php artisan plugin:install RainLab.Builder
+```
+
+To uninstall this plugin:
+
+```bash
+php artisan plugin:remove RainLab.Builder
+```
+
+If you are using October CMS v1 or v2, install v1.2 with the following commands:
+
+```bash
+composer require rainlab/builder-plugin "^1.2"
+```
+
+## Video Tutorial
+
+We also recorded a video tutorial showing how to use the plugin to build a simple library plugin: [watch the video](https://vimeo.com/154415433).
+
+## What You Can Do with Builder
+
+This tool includes multiple features that cover almost all aspects of creating a plugin.
+
+- Initializing a new plugin - this creates the plugin directory along with any necessary files.
+- Creating and editing plugin database tables. All schema changes are saved as regular migration files, so you can easily update the plugin on other servers using your regular workflow.
+- Creating model classes.
+- Creating backend forms with the visual Form Builder.
+- Creating backend lists.
+- Managing a list of user permissions provided by the plugin.
+- Creating plugin backend navigation - in the form of main menu items and sidebar items.
+- Creating backend controllers and configuring their behaviors with a visual tool.
+- Managing plugin versions and updates.
+- Managing plugin localization files.
+- A set of universal components - used for displaying data from the plugin on the front-end in form of lists and single record details.
+- Managing raw code files directly in the backend (**New in v2**).
+- Converting [Tailor blueprints](https://docs.octobercms.com/3.x/cms/tailor/blueprints.html) to plugin files (**New in v2**).
+
+Put simply, you can create a multilingual plugin, that installs database tables, has backend lists and forms protected with user permissions, and adds CMS pages for displaying data managed with the plugin. After learning how Builder works, this process takes just a few minutes.
+
+Builder is a productivity tool, it doesn't completely replace coding by hand and doesn't include a code editor for editing PHP files (the only exception is the version management interface). Builder never overwrites or deletes plugin PHP files, so you can rest assured knowing that the code you write never gets touched by Builder. However, Builder can create new PHP files, like models and controllers.
+
+Most of the visual editors in Builder work with YAML configuration files, which are a native concept in October CMS. For example, after creating a model class in Builder, you can choose to add a form to the model. This operation creates a YAML file in the model's directory.
+
+There are currently some limitations when using Builder. Some of them are missing features which will be added later. Others are ideas intentionally omitted to keep the things simple. As mentioned above, Builder doesn't want to replace coding, while at the same time, it doesn't go too far with visual programming either. The limitations are explained and described in the corresponding sections of the plugin documentation. Those limitations don't mean you can't create any plugin you want - the good old approach to writing the code manually is always applicable for plugins developed with Builder. Builder’s aim is to be a modest, yet powerful tool that is used to accelerate your development cycle.
+
+## Getting Started
+
+Before you create your first plugin with Builder you should configure it. Open the Settings page in October CMS backend and find Builder in the side menu. Enter your author name and namespace. The author name and namespace are required fields and should not change if you wish to publish your plugins on October CMS Marketplace.
+
+If you already have a Marketplace account, use your existing author name and namespace.
+
+## Initializing a New Plugin
+
+On the Builder page in October CMS backend click the small arrow icon in the sidebar to expose the plugin list. After clicking the "Create plugin" button, enter the plugin name and namespace. The default author name and namespace can be pre-filled from the plugin settings. Select the plugin icon, enter the description text and plugin homepage URL (optional).
+
+Please note that you cannot change the namespaces after you create the plugin.
+
+When Builder initializes a plugin, it creates the following files and directories in October's **plugins** directory:
+
+```
+authornamespace
+ pluginnamespace
+ classes
+ lang
+ en
+ lang.php
+ updates
+ version.yaml
+ Plugin.php
+ plugin.yaml
+```
+
+The file **plugin.yaml** contains the basic plugin information - name, description, permissions and backend navigation. This file is managed by the Builder user interface.
+
+The initial contents of the **lang.php** localization file is the plugin name and description. The localization file is created in the default locale of your October installation.
+
+When a new plugin is created, it's automatically selected as the current plugin that Builder works with. You can select another plugin in the plugin list if you need.
+
+## Managing Plugin Database Tables
+
+Tables are managed on the Database tab of Builder. You can create tables, update their structure and delete tables with the visual interface.
+
+Click Add button to open the Create Table tab. Builder automatically generates prefixes for plugin tables. The prefixes are compliant with the [Developer Guidelines](http://octobercms.com/docs/help/developer-guide) for the Marketplace plugins.
+
+Every time when you save changes in a table, Builder shows a popup window with the automatically generated migration PHP code. You can't edit the code in the popup, but you can inspect it or copy to the clipboard. After reviewing the migration, click Save & Apply button. Builder executes the migration immediately and saves the migration file to the plugin's **updates** directory. Afterwards you can find all plugin migrations on the Versions tab of Builder.
+
+> **Important**: Although Builder generates migration files automatically, it can't prevent data loss in some cases when you significantly change the table structure. In some cases it's possible - for example, when you alter length of a string column. Always check the migration PHP code generated by Builder before applying the migration and consider possible consequences of running the migration in a production database.
+
+Currently Builder doesn't allow to manage table indexes with the visual user interface. Unique column management is not supported yet as well. Please use the Version Management feature to manually create migration files.
+
+Please note that the `enum` data type is not currently supported by the Builder due to limitations in the underlying Doctrine classes.
+
+## Managing Models
+
+You can edit models on the Models tab of Builder. Click the Add button, enter the model class name and select a database table from the drop-down list.
+
+The model class name should not contain the namespace. Some examples: Post, Product, Category.
+
+Please note that you cannot delete model files with builder because it would contradict the idea of not deleting or overwriting PHP files with the visual tool. If you need to delete a model, remove its files manually.
+
+## Managing Backend Forms
+
+In October CMS forms belong to models. For every model you can create as many backend forms as you need, but in most cases there is a single form per model.
+
+> **Note**: When you create a form, it's not displayed in October CMS backend until you create a backend controller which uses the form. Read more about controllers below.
+
+On the Models tab in Builder find a model you want to create a form for. Expand the model if needed, hover the **Forms** section and click the plus sign.
+
+Forms in October CMS are defined with YAML files. The default form file name is **fields.yaml**. In the Form Builder, click a placeholder and select a control from the popup list. After that you can click the control and edit it parameters in Inspector.
+
+Almost all form controls have these common properties:
+
+* Field name - a model field name. It's an autocomplete field in Inspector, which allows you to select column names from the underlying database table. Currently relations are not displayed in the autocomplete hints, this will be implemented later. You can enter any model property manually.
+* Label - The control label. You can enter a static text string to the field, or create a new localization string by clicking the plus sign in the input field. Almost all editors in Builder support this feature.
+* Comment - the comment text - fixed text or localization string key.
+* Span - position of the control on the form - left, right, full or automatic placement.
+
+Most of the properties have descriptive names or have a description in Inspector. If you need more information about control properties please refer to the [Documentation](https://docs.octobercms.com/3.x/element/form-fields.html).
+
+You can drag controls in the Form Builder to rearrange them or to move them to/from form tabs. A form tab should have at least one control, otherwise it will be ignored when Form Builder saves the YAML file.
+
+Some form controls, for example the file upload control, require a relation to be created in the model class manually. The relation name should be entered in the Field name property. Please read the [Forms Documentation](https://docs.octobercms.com/3.x/element/form-fields.html) for details about specific form controls.
+
+## Managing Backend Lists
+
+Similarly to forms, backend lists in October CMS belong to models.
+
+> **Note**: When you create a list, it's not displayed in October CMS backend until you create a backend controller which uses the list. Read about controllers below.
+
+On the Models tab in Builder find a model you want to create a list for. Expand the model if needed, hover the **Lists** section and click the plus sign.
+
+Lists in October CMS are defined with YAML files. The default list file name is **columns.yaml**. The grid in the list editor contains list column definitions. Column property names are self descriptive, although some of them require some explanations. Refer to the [Lists documentation](https://docs.octobercms.com/3.x/element/list-columns.html) for details about each property.
+
+For the Label property you can either enter a static string or create a new localization string.
+
+The Field property column has an autocompletion feature attached. It allows you to select columns from the database table that is bound to the model. At the moment it doesn't show relation properties, but you can still type them in manually.
+
+## Managing Plugin Permissions
+
+[Plugin permissions](https://docs.octobercms.com/3.x/extend/backend/users.html) define the features and backend plugin pages a user can access. You can manage permissions on the Permissions tab in Builder. For each permission you should specify a unique permission code, permission tab title and permission label. The tab title and label are displayed in the user management interface on the System page in October backend.
+
+For the tab title and label you can either enter a static string or create a new localization string.
+
+Later, when you create controllers and menu items, you can select what permissions users should have in order to access or see those objects.
+
+## Managing Backend Menus
+
+The [plugin navigation](https://docs.octobercms.com/3.x/extend/backend/navigation.html) is managed on the Backend Menus tab of the Builder. The user interface allows to create top level menu items and sidebar items.
+
+To create a menu item click the placeholder rectangle and then click the new item to open Inspector. In the inspector you can enter the item label, select icon and assign user permissions. The **code** property is required for referring menu items from the controllers code (for marking menu items active).
+
+> **Note**: When you create menu items for backend pages which don't exist yet in the plugin, it makes sense to leave the **URL** property empty until you create the plugin controllers. This property supports autocompletion, so you can just select your controller URLs from the drop-down list.
+
+## Managing Backend Controllers, Forms and Lists
+
+Back-end pages in October CMS are provided with backend controllers. Usually backend pages contain lists and forms for managing plugin records, although you can create any custom controller.
+
+Please refer to the [backend forms](https://docs.octobercms.com/3.x/extend/forms/form-controller.html) and [lists](https://docs.octobercms.com/3.x/extend/lists/list-controller.html) documentation pages for more information about controller behaviors. Currently only List and Form behaviors can be configured with the Builder. If your controller contains other behaviors they won't be removed by the Builder, you just won't be able to edit them with the visual interface.
+
+Builder also allows you to create empty controller classes which don't implement any behaviors and customize them manually.
+
+> **Note**: Some behaviors require specific model features to be implemented. For example, the Reorder Controller behavior requires the model to implement Sortable or NestedTree traits. Always refer to the specific behavior documentation for the implementation details.
+
+To create a controller, click the Add button list on the Controllers tab. Enter the controller class name, for example Posts.
+
+If the controller is going to provide backend lists or forms, select a base model in the drop-down list and select behaviors you want to add. You can also select a top and sidebar menu items that should be active on the controller pages. If needed, choose permissions that users must have to access the controller pages.
+
+> Please note that the settings you enter in the Create Controller popup cannot be changed with Builder. However you can update them manually by editing controller classes.
+
+After creating a controller you can configure its behaviors. Click the controller in list and then click a behavior you want to configure. When Builder creates a controller it tries to apply default configuration to the behaviors, however you might want to change it. Inspector lists displays all supported behavior properties. URL properties (like the list records URLs) are autocomplete fields and populated with URLs of the existing plugin controllers.
+
+## Managing Plugin Versions
+
+Please read the [Version History](https://docs.octobercms.com/3.x/extend/system/plugins.html#version-history) documentation page to understand how versioning works in October CMS.
+
+Basically there are 3 types of version updates:
+
+1. Updates which change the database structure - migrations. Builder can generate migration files automatically when you make changes in the DB schema on the Database tab.
+2. Seeding updates, which populate the database contents.
+3. Version updates, which do not update anything in the database but are often used for releasing code changes.
+
+Plugin versions are managed on the Versions tab of the Builder. This tab displays a list of existing plugin versions their status. Applied versions have a green checkbox marker. Pending versions have a grey clock marker.
+
+You can create a new version with clicking the Add button and selecting the update time. The user interface automatically generates scaffold PHP code for the "Migration" and "Seeder" updates. The "Increase the version number" updates won’t contain any PHP code.
+
+For every version you should specify the new version number and description. Builder generates the version number automatically by increasing the last digit in the existing version. You might want to change it if you're releasing a major version update and want to change the first or second digit.
+
+When a version file is saved, Builder doesn't apply it immediately. You should click the "Apply version" button in the toolbar in order to apply the version and execute the update code (if applicable). You can also rollback already applied version updates, change their code and apply again. This allows you to edit database schema updates generated by Builder if you don't like the default code.
+
+> **Note**: Your migration files should provide correct rollback code in the `down` method in order to use the rollback feature.
+
+When you rollback a version, it automatically rolls back all newer versions. When you apply a version, it automatically applies all pending older versions. Please remember that when a user logs into the backend, October automatically applies all pending updates. Never edit versions on a production server or on a server with multiple backend users - it could cause unpredictable consequences.
+
+Migrations that contain multiple scripts are not supported. They can't be created or edited with Builder.
+
+## Managing Localization
+
+Localization files are managed on the Localization tab of the Builder. When a new plugin is initialized, a single language file is created. This file is created in the default system locale specified in October CMS configuration scripts.
+
+You can create as many language files as you want. Builder UI always displays strings in the October CMS locale, so you might want to update your configuration files to see your plugin in another language.
+
+Please note that although [localization files in October CMS](https://docs.octobercms.com/3.x/extend/system/localization.html) are PHP scripts, they are translated to YAML to simplify the editing in the Builder user interface. When language files are saved, they are translated back to PHP again.
+
+Builder tries to keep the user interface synchronized with your default language file. This means that when you save the language file, Builder automatically updates all localized strings in all editors. In some cases you might need to close and open Inspector in order to re-initialize the autocomplete fields.
+
+In many cases you can create new localization strings on-the-fly from the Builder editors - the Form Builder, Menu Builder, etc. The localization input field has the plus icon on the right side. Clicking the plus icon opens a popup window that allows you to enter the localization string key and value. The string key can contain dots to mark the localization file sections. For example - if you add a string with the key `plugin.posts.category` and value "Enter a category name", Builder will create the following structure in the language file:
+
+```
+plugin:
+ posts:
+ category: Enter a category name
+```
+
+If you create a new localization string from the Inspector or other editor while you have the default language file tab open in the Builder, it will try to update the tab contents or merge the updated file contents from the server. It's a good idea to keep the default localization file always saved in the Builder to avoid possible content conflicts when you edit localization from another place.
+
+> **Tip**: In YAML a single quote is escaped with two single quotes (http://yaml.org/spec/current.html#id2534365).
+
+## Editing Code Files (New in v2)
+
+Raw code and other files can be managed directly in the Code tab of the Builder. You can navigate to any file within the context of the selected plugin.
+
+Use this to make code adjustments without the need for a code editor, which includes creating, moving, renaming and deleting files.
+
+## Importing Tailor Blueprints (New in v2)
+
+The Import tab of the Builder can generate scaffold files using [Tailor blueprints](https://docs.octobercms.com/3.x/cms/tailor/blueprints.html) as a source. In combination, Tailor and Builder work together to create a super-scaffolding tool since it can generate multiple files in one process. First, design your fields and preview them using Tailor, and then when you are ready, import them in to Builder to start working directly with the files.
+
+It important to note that importing blueprints is a one-way function, and in some cases it is better to leave content within Tailor for the benefits that come with its dynamic models, especially for content. This design decision is up to you.
+
+When visiting the Import tab, use the **Add Blueprint** button to select the Tailor blueprints you wish to import. You can select multiple blueprints and it is recommended to include related blueprints so the relationships between blueprints are preserved.
+
+Once added, each blueprint can be customized, which includes the Controller Class, Model Class, Table Name, Permission Code and Menu Code names. When these fields are modified, they will adjust the filenames of the generated files.
+
+Clicking the Import button will begin the conversion process. Some import options are shown to control how the import should proceed.
+
+- **Migrate Database** performs a database migration after the import is finished. This optional and you can migrate the database later.
+
+- **Disable Blueprints** will rename the blueprint files to use a backup extension (.bak) to disable them.
+
+- **Delete Blueprint Data** will delete any existing data and tables for the selected blueprints found within Tailor.
+
+Before clicking **Import**, be sure to double check the selected blueprints. The import process creates multiple scaffold files for the selected plugin. This process can be difficult to undo, so it is a good idea to practice on a test plugin first, without migrating the database or disabling the blueprints.
+
+When everything is done, you should see the controller, model and migration files generated for your selected plugin.
+
+## Displaying Plugin Records on CMS Pages
+
+Builder provides universal CMS components that you can use for displaying records from your plugins on the front-end website pages. The components provide only basic functionality, for example they don't support a record search feature.
+
+Please read the [CMS documentation](https://docs.octobercms.com/3.x/cms/themes/components.html) to learn more about the CMS components concept.
+
+### Record List Component
+
+The Record list component outputs a list of records provided by a plugin's model. The component supports the following optional features: pagination, links to the record details page, using a [model scope](https://docs.octobercms.com/3.x/extend/database/model.html#query-scopes) for the list filtering. The list can be sorted by any column, but the sorting cannot be changed by website visitors - it's set in the component configuration.
+
+Add this component to a CMS page by dragging it to the page code from the component list and click it to configure its properties:
+
+* `Model class` - select a model class you want to use to fetch data from the database.
+* `Scope` - optional, select the model scope method used to filter the results
+* `Scope Value` - optional, the value to provide to the selected scope method. URL parameters can be provided in the form of `{{ :nameOfParam }}`
+* `Display column` - select the model column to display in the list. It's an autocomplete field that displays columns from the underlying database table. You can enter any value in this field. The value is used in the default component partial, you can customize the component by providing custom markup instead of the default partial.
+* `Details page` - a drop-down list of CMS pages you want to create links to.
+* `Details key column` - select a column you want to use as a record identifier in the record links. You can link your records by the primary identifier (id), slug column or any other - it depends on the your database structure.
+* `URL parameter name` - enter the details page URL parameter name, which takes the record identifier. For example, if the record details page has a URL like "/blog/post/:slug", the URL parameter would be "slug".
+* `Records per page` - enter a number to enable pagination for the records.
+* `Page number` - specify the fixed page number or use the **external parameter editor** to enter a name of the URL parameter which holds the page number. For example - if your record list page URL was "/record-list-test/:page?", the Page number property value would be ":page".
+* `Sorting` - select a database column name to use for sorting the list.
+* `Direction` - select whether the sorting should be ascending or descending.
+
+After configuring the component save and preview the page. Most likely you will want to customize the [default component markup](https://docs.octobercms.com/3.x/cms/themes/components.html#customizing-default-markup) to output more details about each record.
+
+> **Tip**: In the CMS editor, you can right-click on the `{% component %}` tag and select "Expand Markup".
+
+## Record Details Component
+
+The Record details component loads a model from the database and outputs its details on a page. If the requested record cannot be found, the component outputs the "record not found" message.
+
+Add this component to a CMS page by dragging it to the page code from the component list and click it to configure its properties:
+
+* `Model class` - select a model class you want to use to fetch data from the database.
+* `Identifier value` - specify a fixed value or use the **external parameter editor** to enter a name of the URL parameter. If the details page had the URL like "/blog/post/:slug", the identifier value would be ":slug".
+* `Key column` - specify a name of the database table column to use for looking up the record. This is an autocomplete field that displays columns from the underlying database table.
+* `Display column` - enter a name of the database table column to display on the details page. The value is used in the default component partial, you can customize the component by providing custom markup instead of the default partial.
+* `Not found message` - a message to display if the record is not found. Used in the default partial.
+
+After configuring the component save and preview the page. You will likely want to customize the [default component markup](https://docs.octobercms.com/3.x/cms/themes/components.html#customizing-default-markup) to output more details from the loaded model.
+
+## Notes About Autocompletion
+
+Builder updates the Inspector autocompletion fields every time when the underlying data is updated. For example, the “Field name” property of the Form Builder controls is populated with the database table column names. If you update the table structure with Builder, the autocompletion cache updates automatically. However you may need to reopen Inspector so that it can update its editors.
+
+If you edit your plugin files or database structure with an external editor, Builder won’t be able to pick up those changes automatically. You might want to reload the Builder page after you add a database column with an external tool in order to refresh the autocompletion features.
+
+## Editing Other Plugins
+
+Although Builder allows you to edit plugins created by other authors, remember that you do it at your own risk. Plugins could be updated by their authors, which will eliminate your changes or break the plugin. In many cases, if you make updates to plugins developed by another author, you lose any technical support provided by the author.
+
+## Adding Support for a Custom Form Widget
+
+To add a custom widget to the Builder plugin, you must first [register a backend form widget](https://docs.octobercms.com/3.x/extend/forms/form-widgets.html#form-widget-registration) for your plugin.
+
+Once it is registered, define a list of [inspector properties](https://docs.octobercms.com/3.x/element/inspector-types.html) within your plugin in the Plugin registration class `boot()` method and register the custom control. For example:
+
+```php
+public function boot()
+{
+ $properties = [
+ 'max_value' => [
+ 'title' => 'The maximum allowed',
+ 'type' => 'builderLocalization',
+ 'validation' => [
+ 'required' => [
+ 'message' => 'Maxium value is required'
+ ]
+ ]
+ ],
+ 'mode' => [
+ 'title' => 'The plugin mode',
+ 'type' => 'dropdown',
+ 'options' => [
+ 'single' => 'Single',
+ 'multiple' => 'Multiple',
+ ],
+ 'ignoreIfEmpty' => true,
+ ]
+ ];
+
+ Event::listen('pages.builder.registerControls', function($controlLibrary) {
+ $controlLibrary->registerControl(
+ 'yourwidgetname',
+ 'My Widget',
+ 'Widget description',
+ ControlLibrary::GROUP_WIDGETS,
+ 'icon-file-image-o',
+ $controlLibrary->getStandardProperties([], $properties),
+ 'Acme\Blog\Classes\ControlDesignTimeProvider'
+ );
+ });
+}
+```
+
+> **Note**: See the `getStandardProperties()` method in the `rainlab/builder/classes/ControlLibrary.php` file for more examples.
+
+Now, we need the `ControlDesignTimeProvider` class referenced above. Save the following as `classes/ControlDesignTimeProvider.php` within your plugin's directory (replacing `'yourwidgetname'` with what you used in your Plugin registration class `boot()` method).
+
+```php
+namespace Acme\Blog\Classes;
+
+use RainLab\Builder\Widgets\DefaultControlDesignTimeProvider;
+
+class ControlDesignTimeProvider extends DefaultControlDesignTimeProvider
+{
+ public function __construct()
+ {
+ $this->defaultControlsTypes[] = 'yourwidgetname';
+ }
+}
+```
+
+Then save the following as `class/controldesigntimeprovider/_control-yourwidgetname.htm` within your plugin's directory, and customize it how you like. Again, `yourwidgetname` in the file name must match:
+
+```html
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/behaviors/indexversionsoperations/partials/_version-hint-block.php b/plugins/rainlab/builder/behaviors/indexversionsoperations/partials/_version-hint-block.php
new file mode 100644
index 0000000..e9eff67
--- /dev/null
+++ b/plugins/rainlab/builder/behaviors/indexversionsoperations/partials/_version-hint-block.php
@@ -0,0 +1,28 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/preview.php.tpl b/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/preview.php.tpl
new file mode 100644
index 0000000..8236c2f
--- /dev/null
+++ b/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/preview.php.tpl
@@ -0,0 +1,22 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/update.php.tpl b/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/update.php.tpl
new file mode 100644
index 0000000..544f44a
--- /dev/null
+++ b/plugins/rainlab/builder/classes/standardbehaviorsregistry/formcontroller/templates/update.php.tpl
@@ -0,0 +1,54 @@
+
+
diff --git a/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_body.php b/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_body.php
new file mode 100644
index 0000000..ad34004
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_body.php
@@ -0,0 +1,10 @@
+
+
+
+ = $this->makePartial('buildingarea') ?>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_buildingarea.php b/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_buildingarea.php
new file mode 100644
index 0000000..5fae3d5
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/controllerbuilder/partials/_buildingarea.php
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.controlpalette.js b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.controlpalette.js
new file mode 100644
index 0000000..f5c9ec6
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.controlpalette.js
@@ -0,0 +1,263 @@
+/*
+ * Manages the control palette loading and displaying
+ */
++function ($) { "use strict";
+
+ var Base = $.oc.foundation.base,
+ BaseProto = Base.prototype
+
+ var ControlPalette = function() {
+ Base.call(this)
+
+ this.controlPaletteMarkup = null
+ this.popoverMarkup = null
+ this.containerMarkup = null
+ this.$popoverContainer = null
+ }
+
+ ControlPalette.prototype = Object.create(BaseProto)
+ ControlPalette.prototype.constructor = ControlPalette
+
+ // INTERNAL METHODS
+ // ============================
+
+ ControlPalette.prototype.loadControlPalette = function(element, controlId) {
+ if (this.controlPaletteMarkup === null) {
+ var data = {
+ controlId: controlId
+ }
+
+ $.oc.stripeLoadIndicator.show()
+ $(element).request('onModelFormLoadControlPalette', {
+ data: data
+ }).done(
+ this.proxy(this.controlPaletteMarkupLoaded)
+ ).always(function(){
+ $.oc.stripeLoadIndicator.hide()
+ })
+ }
+ else {
+ this.showControlPalette(controlId, true)
+ }
+ }
+
+ ControlPalette.prototype.controlPaletteMarkupLoaded = function(responseData) {
+ this.controlPaletteMarkup = responseData.markup
+
+ this.showControlPalette(responseData.controlId)
+ }
+
+ ControlPalette.prototype.getControlById = function(controlId) {
+ return document.body.querySelector('li[data-builder-control-id="'+controlId+'"]')
+ }
+
+ ControlPalette.prototype.showControlPalette = function(controlId, initControls) {
+ if (this.getContainerPreference()) {
+ this.showControlPalletteInContainer(controlId, initControls)
+ }
+ else {
+ this.showControlPalletteInPopup(controlId, initControls)
+ }
+ }
+
+ ControlPalette.prototype.assignControlIdToTemplate = function(template, controlId) {
+ return template.replace('%c', controlId)
+ }
+
+ ControlPalette.prototype.markPlaceholderPaletteOpen = function(control) {
+ $(control).addClass('control-palette-open')
+ }
+
+ ControlPalette.prototype.markPlaceholderPaletteNotOpen = function(control) {
+ $(control).removeClass('control-palette-open')
+ }
+
+ ControlPalette.prototype.getContainerPreference = function() {
+ return $.oc.inspector.manager.getContainerPreference()
+ }
+
+ ControlPalette.prototype.setContainerPreference = function(value) {
+ return $.oc.inspector.manager.setContainerPreference(value)
+ }
+
+ ControlPalette.prototype.addControl = function(ev) {
+ var $target = $(ev.currentTarget),
+ controlId = $target.closest('[data-control-palette-controlid]').attr('data-control-palette-controlid')
+
+ ev.preventDefault()
+ ev.stopPropagation()
+
+ if (!controlId) {
+ return false;
+ }
+
+ var control = this.getControlById(controlId)
+ if (!control) {
+ return false
+ }
+
+ if ($(control).hasClass('loading-control')) {
+ return false
+ }
+
+ $target.trigger('close.oc.popover')
+
+ var promise = $.oc.builder.formbuilder.controller.addControlFromControlPalette(controlId,
+ $target.data('builderControlType'),
+ $target.data('builderControlName'))
+
+ promise.done(function() {
+ $.oc.inspector.manager.createInspector(control)
+ $(control).trigger('change') // Set modified state for the form
+ })
+
+ return false
+ }
+
+ //
+ // Popover wrapper
+ //
+
+ ControlPalette.prototype.showControlPalletteInPopup = function(controlId, initControls) {
+ var control = this.getControlById(controlId)
+
+ if (!control) {
+ return
+ }
+
+ var $control = $(control)
+
+ $control.ocPopover({
+ content: this.assignControlIdToTemplate(this.getPopoverMarkup(), controlId),
+ highlightModalTarget: true,
+ modal: true,
+ placement: 'below',
+ containerClass: 'control-inspector',
+ offset: 15,
+ width: 400
+ })
+
+ var $popoverContainer = $control.data('oc.popover').$container
+
+ if (initControls) {
+ // Initialize the scrollpad control in the popup only when the
+ // popup is created from the cached markup string
+ $popoverContainer.trigger('render')
+ }
+ }
+
+ ControlPalette.prototype.getPopoverMarkup = function() {
+ if (this.popoverMarkup !== null) {
+ return this.popoverMarkup
+ }
+
+ var outerMarkup = $('script[data-template=control-palette-popover]').html()
+
+ this.popoverMarkup = outerMarkup.replace('%s', this.controlPaletteMarkup)
+
+ return this.popoverMarkup
+ }
+
+ ControlPalette.prototype.dockToContainer = function(ev) {
+ var $popoverBody = $(ev.target).closest('.control-popover'),
+ $controlIdContainer = $popoverBody.find('[data-control-palette-controlid]'),
+ controlId = $controlIdContainer.attr('data-control-palette-controlid'),
+ control = this.getControlById(controlId)
+
+ $popoverBody.trigger('close.oc.popover')
+
+ this.setContainerPreference(true)
+
+ if (control) {
+ this.loadControlPalette($(control), controlId)
+ }
+ }
+
+ //
+ // Container wrapper
+ //
+
+ ControlPalette.prototype.showControlPalletteInContainer = function(controlId, initControls) {
+ var control = this.getControlById(controlId)
+
+ if (!control) {
+ return
+ }
+
+ var inspectorManager = $.oc.inspector.manager,
+ $container = inspectorManager.getContainerElement($(control))
+
+ // If the container is already in use, apply values to the inspectable elements
+ if (!inspectorManager.applyValuesFromContainer($container) || !inspectorManager.containerHidingAllowed($container)) {
+ return
+ }
+
+ // Dispose existing Inspector
+ $.oc.foundation.controlUtils.disposeControls($container.get(0))
+
+ this.markPlaceholderPaletteOpen(control)
+
+ var template = this.assignControlIdToTemplate(this.getContainerMarkup(), controlId)
+ $container.append(template)
+
+ $container.find('[data-control-palette-controlid]').one('dispose-control', this.proxy(this.onRemovePaletteFromContainer))
+
+ if (initControls) {
+ // Initialize the scrollpad control in the container only when the
+ // palette is created from the cached markup string
+ $container.trigger('render')
+ }
+ }
+
+ ControlPalette.prototype.onRemovePaletteFromContainer = function(ev) {
+ this.removePaletteFromContainer($(ev.target))
+ }
+
+ ControlPalette.prototype.removePaletteFromContainer = function($container) {
+ var controlId = $container.attr('data-control-palette-controlid'),
+ control = this.getControlById(controlId)
+
+ if (control) {
+ this.markPlaceholderPaletteNotOpen(control)
+ }
+
+ var $parent = $container.parent()
+ $container.remove()
+ $parent.html('')
+ }
+
+ ControlPalette.prototype.getContainerMarkup = function() {
+ if (this.containerMarkup !== null) {
+ return this.containerMarkup
+ }
+
+ var outerMarkup = $('script[data-template=control-palette-container]').html()
+
+ this.containerMarkup = outerMarkup.replace('%s', this.controlPaletteMarkup)
+
+ return this.containerMarkup
+ }
+
+ ControlPalette.prototype.closeInContainer = function(ev) {
+ this.removePaletteFromContainer($(ev.target).closest('[data-control-palette-controlid]'))
+ }
+
+ ControlPalette.prototype.undockFromContainer = function(ev) {
+ var $container = $(ev.target).closest('[data-control-palette-controlid]'),
+ controlId = $container.attr('data-control-palette-controlid'),
+ control = this.getControlById(controlId)
+
+ this.removePaletteFromContainer($container)
+ this.setContainerPreference(false)
+
+ if (control) {
+ this.loadControlPalette($(control), controlId)
+ }
+ }
+
+ $(document).ready(function(){
+ // There is a single instance of the control palette manager.
+ $.oc.builder.formbuilder.controlPalette = new ControlPalette()
+ })
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.domtopropertyjson.js b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.domtopropertyjson.js
new file mode 100644
index 0000000..95e530e
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.domtopropertyjson.js
@@ -0,0 +1,350 @@
+/*
+ * Converts control properties from DOM elements to JSON format.
+ */
++function ($) { "use strict";
+
+ if ($.oc.builder === undefined)
+ $.oc.builder = {}
+
+ if ($.oc.builder.formbuilder === undefined)
+ $.oc.builder.formbuilder = {}
+
+ function getControlPropertyValues(item) {
+ for (var i=0, len=item.children.length; i 0 && properties['oc.commentPosition'] == 'above') {
+ properties['commentAbove'] = properties['oc.comment']
+
+ if (properties['comment'] !== undefined) {
+ delete properties['comment']
+ }
+
+ delete properties['oc.comment']
+ delete properties['oc.commentPosition']
+ }
+
+ if (String(properties['oc.comment']).length > 0 && properties['oc.commentPosition'] == 'below') {
+ properties['comment'] = properties['oc.comment']
+
+ if (properties['comentAbove'] !== undefined) {
+ delete properties['comentAbove']
+ }
+
+ delete properties['oc.comment']
+ delete properties['oc.commentPosition']
+ }
+
+ if (properties['oc.comment'] !== undefined) {
+ if (String(properties['oc.comment']).length > 0) {
+ properties['comment'] = properties['oc.comment']
+ }
+
+ delete properties['oc.comment']
+ }
+ }
+
+ function parseControlControlContainer(control) {
+ var children = control.children,
+ result = {}
+
+ for (var i=0, len=children.length; i 0) {
+ if (objectHasProperties(controls)) {
+ if (result[listName].fields === undefined) {
+ result[listName].fields = {}
+ }
+
+ result[listName].fields = $.extend(result[listName].fields, controls)
+ }
+ }
+ else {
+ if (objectHasProperties(controls)) {
+ if (result.fields === undefined) {
+ result.fields = {}
+ }
+
+ result.fields = $.extend(result.fields, controls)
+ }
+ }
+ }
+
+ function containerToJson(container) {
+ var containerElements = container.children,
+ result = {}
+
+ for (var i=0, len=containerElements.length; i li.control'),
+ result = []
+
+ for (var i=controls.length-1; i>=0; i--) {
+ var properties = getControlPropertyValues(controls[i])
+
+ if (typeof properties !== 'object') {
+ continue
+ }
+
+ if (properties['oc.fieldName'] === undefined) {
+ continue
+ }
+
+ var name = properties['oc.fieldName']
+
+ if (result.indexOf(name) === -1) {
+ result.push(name)
+ }
+ }
+
+ result.sort()
+
+ return result
+ }
+
+
+ $.oc.builder.formbuilder.domToPropertyJson = DomToJson
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.js b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.js
new file mode 100644
index 0000000..1f7d38b
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.js
@@ -0,0 +1,803 @@
+/*
+ * Form Builder widget class.
+ *
+ * There is only a single instance of the Form Builder class and it handles
+ * as many form builder user interfaces as needed.
+ *
+ */
++function ($) { "use strict";
+
+ var Base = $.oc.foundation.base,
+ BaseProto = Base.prototype
+
+ var FormBuilder = function() {
+ Base.call(this);
+
+ this.placeholderIdIndex = 0;
+ this.updateControlBodyTimer = null;
+
+ this.init();
+ }
+
+ FormBuilder.prototype = Object.create(BaseProto)
+ FormBuilder.prototype.constructor = FormBuilder
+
+ // INTERNAL METHODS
+ // ============================
+
+ FormBuilder.prototype.init = function() {
+ this.registerHandlers()
+ }
+
+ FormBuilder.prototype.registerHandlers = function() {
+ document.addEventListener('dragstart', this.proxy(this.onDragStart))
+ document.addEventListener('dragover', this.proxy(this.onDragOver))
+ document.addEventListener('dragenter', this.proxy(this.onDragEnter))
+ document.addEventListener('dragleave', this.proxy(this.onDragLeave))
+ document.addEventListener('drop', this.proxy(this.onDragDrop), false);
+
+ $(document).on('change', '.builder-control-list > li.control', this.proxy(this.onControlChange))
+ $(document).on('click', '.builder-control-list > li.control div[data-builder-remove-control]', this.proxy(this.onRemoveControl))
+ $(document).on('click', '.builder-control-list > li.oc-placeholder', this.proxy(this.onPlaceholderClick))
+ $(document).on('showing.oc.inspector', '.builder-control-list > li.control', this.proxy(this.onInspectorShowing))
+ $(document).on('livechange', '.builder-control-list > li.control', this.proxy(this.onControlLiveChange))
+ $(document).on('autocompleteitems.oc.inspector', '.builder-control-list > li.control', this.proxy(this.onAutocompleteItems))
+ $(document).on('dropdownoptions.oc.inspector', '.builder-control-list > li.control', this.proxy(this.onDropdownOptions))
+ }
+
+ FormBuilder.prototype.getControlId = function(li) {
+ if (li.hasAttribute('data-builder-control-id')) {
+ return li.getAttribute('data-builder-control-id')
+ }
+
+ this.placeholderIdIndex++
+ li.setAttribute('data-builder-control-id', this.placeholderIdIndex)
+
+ return this.placeholderIdIndex
+ }
+
+ // PROPERTY HELPERS
+ // ============================
+
+ FormBuilder.prototype.getControlProperties = function(li) {
+ var children = li.children
+
+ for (var i=children.length-1; i>=0; i--) {
+ var element = children[i]
+
+ if (element.tagName === 'INPUT' && element.hasAttribute('data-inspector-values')) {
+ return $.parseJSON(element.value)
+ }
+ }
+
+ throw new Error('Inspector values element is not found in control.')
+ }
+
+ FormBuilder.prototype.setControlProperties = function(li, propertiesObj) {
+ var propertiesStr = JSON.stringify(propertiesObj),
+ valuesInput = li.querySelector('[data-inspector-values]')
+
+ valuesInput.value = propertiesStr
+ }
+
+ FormBuilder.prototype.loadModelFields = function(control, callback) {
+ var $form = $(this.findForm(control)),
+ pluginCode = $.oc.builder.indexController.getFormPluginCode($form),
+ modelClass = $form.find('input[name=model_class]').val()
+
+ $.oc.builder.dataRegistry.get($form, pluginCode, 'model-columns', modelClass, function(response){
+ callback({
+ options: $.oc.builder.indexController.dataToInspectorArray(response)
+ })
+ })
+ }
+
+ FormBuilder.prototype.getContainerFieldNames = function(control, callback) {
+ var controlWrapper = this.findRootControlWrapper(control),
+ fieldNames = $.oc.builder.formbuilder.domToPropertyJson.getAllControlNames(controlWrapper),
+ options = []
+
+ options.push({
+ title: '---',
+ value: ''
+ })
+
+ for (var i=0, len=fieldNames.length; i=0; i--) {
+ var value = String(valueInputs[i].value)
+
+ if (value.length === 0) {
+ continue
+ }
+
+ var properties = $.parseJSON(value)
+
+ if (properties['oc.fieldName'] == fieldName) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ // FLOW MANAGEMENT
+ // ============================
+
+ FormBuilder.prototype.reflow = function(li, listElement) {
+ if (!li && !listElement) {
+ throw new Error('Invalid call of the reflow method. Either li or list parameter should be not empty.')
+ }
+
+ var list = listElement ? listElement : li.parentNode,
+ items = list.children,
+ prevSpan = null
+
+ for (var i=0, len = items.length; i < len; i++) {
+ var item = items[i],
+ itemSpan = item.getAttribute('data-builder-span')
+
+ if ($.oc.foundation.element.hasClass(item, 'clear-row')) {
+ continue
+ }
+
+ if (itemSpan == 'auto') {
+ $.oc.foundation.element.removeClass(item, 'span-left')
+ $.oc.foundation.element.removeClass(item, 'span-full')
+ $.oc.foundation.element.removeClass(item, 'span-right')
+
+ if (prevSpan == 'left') {
+ $.oc.foundation.element.addClass(item, 'span-right')
+ prevSpan = 'right'
+ }
+ else {
+ if (!$.oc.foundation.element.hasClass(item, 'oc-placeholder')) {
+ $.oc.foundation.element.addClass(item, 'span-left')
+ }
+ else {
+ $.oc.foundation.element.addClass(item, 'span-full')
+ }
+
+ prevSpan = 'left'
+ }
+ }
+ else {
+ $.oc.foundation.element.removeClass(item, 'span-left')
+ $.oc.foundation.element.removeClass(item, 'span-full')
+ $.oc.foundation.element.removeClass(item, 'span-right')
+ $.oc.foundation.element.addClass(item, 'span-' + itemSpan)
+
+ prevSpan = itemSpan
+ }
+ }
+ }
+
+ FormBuilder.prototype.setControlSpanFromProperties = function(li, properties) {
+ if (properties.span === undefined) {
+ return
+ }
+
+ li.setAttribute('data-builder-span', properties.span)
+ this.reflow(li)
+ }
+
+ FormBuilder.prototype.appendClearRowElement = function(li) {
+ li.insertAdjacentHTML('afterend', '');
+ }
+
+ FormBuilder.prototype.patchControlSpan = function(li, span) {
+ li.setAttribute('data-builder-span', span)
+
+ var properties = this.getControlProperties(li)
+ properties.span = span
+ this.setControlProperties(li, properties)
+ }
+
+ // DRAG AND DROP
+ // ============================
+
+ FormBuilder.prototype.targetIsPlaceholder = function(ev) {
+ if (!ev.target.getAttribute) {
+ return false // In Gecko ev.target could be a text node
+ }
+
+ return ev.target.getAttribute('data-builder-placeholder')
+ }
+
+ FormBuilder.prototype.dataTransferContains = function(ev, element) {
+ if (ev.dataTransfer.types.indexOf !== undefined){
+ return ev.dataTransfer.types.indexOf(element) >= 0
+ }
+
+ return ev.dataTransfer.types.contains(element)
+ }
+
+ FormBuilder.prototype.sourceIsContainer = function(ev) {
+ return this.dataTransferContains(ev, 'builder/source/container')
+ }
+
+ FormBuilder.prototype.startDragFromContainer = function(ev) {
+ ev.dataTransfer.effectAllowed = 'move'
+
+ var controlId = this.getControlId(ev.target)
+ ev.dataTransfer.setData('builder/source/container', 'true')
+ ev.dataTransfer.setData('builder/control/id', controlId)
+ ev.dataTransfer.setData(controlId, controlId)
+ }
+
+ FormBuilder.prototype.dropTargetIsChildOf = function(target, ev) {
+ var current = target
+
+ while (current) {
+ if (this.elementIsControl(current) && this.dataTransferContains(ev, this.getControlId(current))) {
+ return true
+ }
+
+ current = current.parentNode
+ }
+
+ return false
+ }
+
+ FormBuilder.prototype.dropFromContainerToPlaceholderOrControl = function(ev, targetControl) {
+ var targetElement = targetControl ? targetControl : ev.target
+
+ $.oc.foundation.event.stop(ev)
+ this.stopHighlightingTargets(targetElement)
+
+ var controlId = ev.dataTransfer.getData('builder/control/id'),
+ originalControl = document.body.querySelector('li[data-builder-control-id="'+controlId+'"]')
+
+ if (!originalControl) {
+ return
+ }
+
+ var isSameList = originalControl.parentNode === targetElement.parentNode,
+ originalList = originalControl.parentNode,
+ $originalClearRow = $(originalControl).next()
+
+ targetElement.parentNode.insertBefore(originalControl, targetElement)
+
+ this.appendClearRowElement(originalControl)
+ if ($originalClearRow.hasClass('clear-row')) {
+ $originalClearRow.remove()
+ }
+
+ if (!$.oc.foundation.element.hasClass(originalControl, 'inspector-open')) {
+ this.patchControlSpan(originalControl, 'auto')
+ }
+
+ this.reflow(targetElement)
+
+ if (!isSameList) {
+ this.reflow(null, originalList)
+ }
+
+ $(targetElement).closest('form').trigger('change')
+ }
+
+ FormBuilder.prototype.elementContainsPoint = function(point, element) {
+ var elementPosition = $.oc.foundation.element.absolutePosition(element),
+ elementRight = elementPosition.left + element.offsetWidth,
+ elementBottom = elementPosition.top + element.offsetHeight
+
+ return point.x >= elementPosition.left && point.x <= elementRight
+ && point.y >= elementPosition.top && point.y <= elementBottom
+ }
+
+ FormBuilder.prototype.stopHighlightingTargets = function(target, excludeTarget) {
+ var rootWrapper = this.findRootControlWrapper(target),
+ controls = rootWrapper.querySelectorAll('li.control.drag-over')
+
+ for (var i=controls.length-1; i>= 0; i--) {
+ if (!excludeTarget || target !== controls[i]) {
+ $.oc.foundation.element.removeClass(controls[i], 'drag-over')
+ }
+ }
+ }
+
+ // UPDATING CONTROLS
+ // ============================
+
+ FormBuilder.prototype.startUpdateControlBody = function(controlId) {
+ this.clearUpdateControlBodyTimer()
+
+ var self = this
+ this.updateControlBodyTimer = window.setTimeout(function(){
+ self.updateControlBody(controlId)
+ }, 300)
+ }
+
+ FormBuilder.prototype.clearUpdateControlBodyTimer = function() {
+ if (this.updateControlBodyTimer === null) {
+ return
+ }
+
+ clearTimeout(this.updateControlBodyTimer)
+ this.updateControlBodyTimer = null
+ }
+
+ FormBuilder.prototype.updateControlBody = function(controlId) {
+ var control = document.body.querySelector('li[data-builder-control-id="'+controlId+'"]')
+ if (!control) {
+ return
+ }
+
+ this.clearUpdateControlBodyTimer()
+
+ var rootWrapper = this.findRootControlWrapper(control),
+ controls = rootWrapper.querySelectorAll('li.control.updating-control')
+
+ for (var i=controls.length-1; i>=0; i--) {
+ $.oc.foundation.element.removeClass(controls[i], 'updating-control')
+ }
+
+ $.oc.foundation.element.addClass(control, 'updating-control')
+
+ var controlType = control.getAttribute('data-control-type'),
+ properties = this.getControlProperties(control),
+ data = {
+ controlType: controlType,
+ controlId: controlId,
+ properties: properties
+ }
+
+ $(control).request('onModelFormRenderControlBody', {
+ data: data
+ }).done(
+ this.proxy(this.controlBodyMarkupLoaded)
+ ).always(function(){
+ $.oc.foundation.element.removeClass(control, 'updating-control')
+ })
+ }
+
+ FormBuilder.prototype.controlBodyMarkupLoaded = function(responseData) {
+ var li = document.body.querySelector('li[data-builder-control-id="'+responseData.controlId+'"]')
+ if (!li) {
+ return
+ }
+
+ var wrapper = li.querySelector('.control-wrapper')
+
+ wrapper.innerHTML = responseData.markup
+ }
+
+ // ADDING CONTROLS
+ // ============================
+
+ FormBuilder.prototype.generateFieldName = function(controlType, placeholder) {
+ var controlContainer = this.findControlContainer(placeholder)
+
+ if (!controlContainer) {
+ throw new Error('Cannot find control container for a placeholder.')
+ }
+
+ // Replace any banned characters
+ controlType = controlType.replace(/[^a-zA-Z0-9_\[\]]/g, '')
+
+ var counter = 1,
+ fieldName = controlType + counter
+
+ while (this.fieldNameExistsInContainer(controlContainer, fieldName)) {
+ counter ++
+ fieldName = controlType + counter
+ }
+
+ return fieldName
+ }
+
+ FormBuilder.prototype.addControlToPlaceholder = function(placeholder, controlType, controlName, noNewPlaceholder, fieldName) {
+ // Duplicate the placeholder and place it after
+ // the existing one
+ if (!noNewPlaceholder) {
+ var newPlaceholder = $(placeholder.outerHTML)
+
+ newPlaceholder.removeAttr('data-builder-control-id')
+ newPlaceholder.removeClass('control-palette-open')
+
+ placeholder.insertAdjacentHTML('afterend', newPlaceholder.get(0).outerHTML)
+ }
+
+ // Create the clear-row element after the current placeholder
+ this.appendClearRowElement(placeholder)
+
+ // Replace the placeholder class with control
+ // loading indicator
+ $.oc.foundation.element.removeClass(placeholder, 'oc-placeholder')
+ $.oc.foundation.element.addClass(placeholder, 'loading-control')
+ $.oc.foundation.element.removeClass(placeholder, 'control-palette-open')
+ placeholder.innerHTML = ''
+ placeholder.removeAttribute('data-builder-placeholder')
+
+ if (!fieldName) {
+ fieldName = this.generateFieldName(controlType, placeholder)
+ }
+
+ // Send request to the server to load the
+ // control markup, Inspector data schema, inspector title, etc.
+ var data = {
+ controlType: controlType,
+ controlId: this.getControlId(placeholder),
+ properties: {
+ 'label': controlName,
+ 'span': 'auto',
+ 'oc.fieldName': fieldName
+ }
+ }
+ this.reflow(placeholder)
+
+ return $(placeholder).request('onModelFormRenderControlWrapper', {
+ data: data
+ }).done(this.proxy(this.controlWrapperMarkupLoaded))
+ }
+
+ FormBuilder.prototype.controlWrapperMarkupLoaded = function(responseData) {
+ var placeholder = document.body.querySelector('li[data-builder-control-id="'+responseData.controlId+'"]')
+ if (!placeholder) {
+ return
+ }
+
+ placeholder.setAttribute('draggable', true)
+ placeholder.setAttribute('data-inspectable', true)
+ placeholder.setAttribute('data-control-type', responseData.type)
+
+ placeholder.setAttribute('data-inspector-title', responseData.controlTitle)
+ placeholder.setAttribute('data-inspector-description', responseData.description)
+
+ placeholder.innerHTML = responseData.markup
+ $.oc.foundation.element.removeClass(placeholder, 'loading-control')
+ }
+
+ FormBuilder.prototype.displayControlPaletteForPlaceholder = function(element) {
+ $.oc.builder.formbuilder.controlPalette.loadControlPalette(element, this.getControlId(element))
+ }
+
+ FormBuilder.prototype.addControlFromControlPalette = function(placeholderId, controlType, controlName) {
+ var placeholder = document.body.querySelector('li[data-builder-control-id="'+placeholderId+'"]')
+ if (!placeholder) {
+ return
+ }
+
+ return this.addControlToPlaceholder(placeholder, controlType, controlName)
+ }
+
+ // REMOVING CONTROLS
+ // ============================
+
+ FormBuilder.prototype.removeControl = function($control) {
+ if ($control.hasClass('inspector-open')) {
+ var $inspectorContainer = this.findInspectorContainer($control)
+ $.oc.foundation.controlUtils.disposeControls($inspectorContainer.get(0))
+ }
+
+ var $nextControl = $control.next() // Even if the removed element was alone, there's always a placeholder element
+ $control.remove()
+
+ this.reflow($nextControl.get(0))
+ $nextControl.trigger('change')
+ }
+
+ // DOM HELPERS
+ // ============================
+
+ FormBuilder.prototype.findControlContainer = function(element) {
+ var current = element
+
+ while (current) {
+ if (current.hasAttribute && current.hasAttribute('data-control-container') ) {
+ return current
+ }
+
+ current = current.parentNode
+ }
+
+ return null
+ }
+
+ FormBuilder.prototype.findForm = function(element) {
+ var current = element
+
+ while (current) {
+ if (current.tagName === 'FORM') {
+ return current
+ }
+
+ current = current.parentNode
+ }
+
+ return null
+ }
+
+ FormBuilder.prototype.findControlList = function(element) {
+ var current = element
+
+ while (current) {
+ if (current.hasAttribute('data-control-list')) {
+ return current
+ }
+
+ current = current.parentNode
+ }
+
+ throw new Error('Cannot find control list for an element.')
+ }
+
+ FormBuilder.prototype.findPlaceholder = function(controlList) {
+ var children = controlList.children
+
+ for (var i=children.length-1; i>=0; i--) {
+ var element = children[i]
+
+ if (element.tagName === 'LI' && $.oc.foundation.element.hasClass(element, 'oc-placeholder')) {
+ return element
+ }
+ }
+
+ throw new Error('Cannot find placeholder in a control list.')
+ }
+
+ FormBuilder.prototype.findRootControlWrapper = function(control) {
+ var current = control
+
+ while (current) {
+ if (current.hasAttribute('data-root-control-wrapper')) {
+ return current
+ }
+
+ current = current.parentNode
+ }
+
+ throw new Error('Cannot find root control wrapper.')
+ }
+
+ FormBuilder.prototype.findInspectorContainer = function($element) {
+ var $containerRoot = $element.closest('[data-inspector-container]')
+
+ return $containerRoot.find('.inspector-container')
+ }
+
+ FormBuilder.prototype.elementIsControl = function(element) {
+ return element.tagName === 'LI' && element.hasAttribute('data-control-type') && $.oc.foundation.element.hasClass(element, 'control')
+ }
+
+ FormBuilder.prototype.getClosestControl = function(element) {
+ var current = element
+
+ while (current) {
+ if (this.elementIsControl(current)) {
+ return current
+ }
+
+ current = current.parentNode
+ }
+
+ return null
+ }
+
+ // EVENT HANDLERS
+ // ============================
+
+ FormBuilder.prototype.onDragStart = function(ev) {
+ if (this.elementIsControl(ev.target)) {
+ this.startDragFromContainer(ev)
+
+ return
+ }
+ }
+
+ FormBuilder.prototype.onDragOver = function(ev) {
+ var targetLi = ev.target
+
+ if (ev.target.tagName !== 'LI') {
+ targetLi = this.getClosestControl(ev.target)
+ }
+
+ if (!targetLi || targetLi.tagName != 'LI') {
+ return
+ }
+
+ var sourceIsContainer = this.sourceIsContainer(ev),
+ elementIsControl = this.elementIsControl(targetLi)
+
+ if ((this.targetIsPlaceholder(ev) || elementIsControl) && sourceIsContainer) {
+ // Do not allow dropping controls to themselves or their
+ // children controls.
+ if (sourceIsContainer && elementIsControl && this.dropTargetIsChildOf(targetLi, ev)) {
+ return false
+ }
+
+ // Dragging from container over a placeholder or another control.
+ // Allow the drop.
+ $.oc.foundation.event.stop(ev)
+ ev.dataTransfer.dropEffect = 'move'
+ return
+ }
+ }
+
+ FormBuilder.prototype.onDragEnter = function(ev) {
+ var targetLi = ev.target
+
+ if (ev.target.tagName !== 'LI') {
+ targetLi = this.getClosestControl(ev.target)
+ }
+
+ if (!targetLi || targetLi.tagName != 'LI') {
+ return
+ }
+
+ var sourceIsContainer = this.sourceIsContainer(ev)
+
+ if (this.targetIsPlaceholder(ev) && sourceIsContainer) {
+ // Do not allow dropping controls to themselves or their
+ // children controls.
+ if (sourceIsContainer && this.dropTargetIsChildOf(ev.target, ev)) {
+ this.stopHighlightingTargets(ev.target, true)
+ return
+ }
+
+ // Dragging from a container over a placeholder.
+ // Highlight the placeholder.
+ $.oc.foundation.element.addClass(ev.target, 'drag-over')
+ return
+ }
+
+ var elementIsControl = this.elementIsControl(targetLi)
+
+ if (elementIsControl && sourceIsContainer) {
+ // Do not allow dropping controls to themselves or their
+ // children controls.
+ if (sourceIsContainer && elementIsControl && this.dropTargetIsChildOf(targetLi, ev)) {
+ this.stopHighlightingTargets(targetLi, true)
+ return
+ }
+
+ // Dragging from a container over another control.
+ // Highlight the other control.
+ $.oc.foundation.element.addClass(targetLi, 'drag-over')
+
+ this.stopHighlightingTargets(targetLi, true)
+
+ return
+ }
+ }
+
+ FormBuilder.prototype.onDragLeave = function(ev) {
+ var targetLi = ev.target
+
+ if (ev.target.tagName !== 'LI') {
+ targetLi = this.getClosestControl(ev.target)
+ }
+
+ if (!targetLi || targetLi.tagName != 'LI') {
+ return
+ }
+
+ if (this.targetIsPlaceholder(ev) && this.sourceIsContainer(ev)) {
+ // Dragging from a container over a placeholder.
+ // Stop highlighting the placeholder.
+ this.stopHighlightingTargets(ev.target)
+
+ return
+ }
+
+ if (this.elementIsControl(targetLi) && this.sourceIsContainer(ev)) {
+ // Dragging from a container over another control.
+ // Stop highlighting the other control.
+ var mousePosition = $.oc.foundation.event.pageCoordinates(ev)
+
+ if (!this.elementContainsPoint(mousePosition, targetLi)) {
+ this.stopHighlightingTargets(targetLi)
+ }
+ }
+ }
+
+ FormBuilder.prototype.onDragDrop = function(ev) {
+ var targetLi = ev.target
+
+ if (ev.target.tagName !== 'LI') {
+ targetLi = this.getClosestControl(ev.target)
+ }
+
+ if (!targetLi || targetLi.tagName != 'LI') {
+ return
+ }
+
+ var elementIsControl = this.elementIsControl(targetLi),
+ sourceIsContainer = this.sourceIsContainer(ev)
+
+ if ((elementIsControl || this.targetIsPlaceholder(ev)) && sourceIsContainer) {
+ this.stopHighlightingTargets(targetLi)
+
+ if (this.dropTargetIsChildOf(targetLi, ev)) {
+ return
+ }
+
+ // Dropped from a container to a placeholder or another control.
+ // Stop highlighting the placeholder, move the control.
+ this.dropFromContainerToPlaceholderOrControl(ev, targetLi)
+ return
+ }
+ }
+
+ FormBuilder.prototype.onControlChange = function(ev) {
+ // Control has changed (with Inspector) -
+ // update the control markup with AJAX
+
+ var li = ev.currentTarget,
+ properties = this.getControlProperties(li)
+
+ this.setControlSpanFromProperties(li, properties)
+ this.updateControlBody(this.getControlId(li))
+
+ ev.stopPropagation()
+ return false
+ }
+
+ FormBuilder.prototype.onControlLiveChange = function(ev) {
+ $(this.findForm(ev.currentTarget)).trigger('change') // Set modified state for the form
+
+ var li = ev.currentTarget,
+ propertiesParsed = this.getControlProperties(li)
+
+ this.setControlSpanFromProperties(li, propertiesParsed)
+ this.startUpdateControlBody(this.getControlId(li))
+
+ ev.stopPropagation()
+ return false
+ }
+
+ FormBuilder.prototype.onAutocompleteItems = function(ev, data) {
+ if (data.propertyDefinition.fillFrom === 'model-fields') {
+ ev.preventDefault()
+ this.loadModelFields(ev.target, data.callback)
+ }
+ }
+
+ FormBuilder.prototype.onDropdownOptions = function(ev, data) {
+ if (data.propertyDefinition.fillFrom === 'form-controls') {
+ this.getContainerFieldNames(ev.target, data.callback)
+ ev.preventDefault()
+ }
+ }
+
+ FormBuilder.prototype.onRemoveControl = function(ev) {
+ this.removeControl($(ev.target).closest('li.control'))
+
+ ev.preventDefault()
+ ev.stopPropagation()
+
+ return false
+ }
+
+ FormBuilder.prototype.onInspectorShowing = function(ev) {
+ if ($(ev.target).find('input[data-non-inspectable-control]').length > 0) {
+ ev.preventDefault()
+ return false
+ }
+ }
+
+ FormBuilder.prototype.onPlaceholderClick = function(ev) {
+ this.displayControlPaletteForPlaceholder(ev.target)
+ ev.stopPropagation()
+ ev.preventDefault()
+ return false;
+ }
+
+ $(document).ready(function(){
+ // There is a single instance of the form builder. All operations
+ // are stateless, so instance properties or DOM references are not needed.
+ $.oc.builder.formbuilder.controller = new FormBuilder()
+ })
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.tabs.js b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.tabs.js
new file mode 100644
index 0000000..3123d25
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/assets/js/formbuilder.tabs.js
@@ -0,0 +1,286 @@
+/*
+ * Manages tabs in the form builder area.
+ */
++function ($) { "use strict";
+
+ var Base = $.oc.foundation.base,
+ BaseProto = Base.prototype
+
+ var TabManager = function() {
+ Base.call(this)
+
+ this.init()
+ }
+
+ TabManager.prototype = Object.create(BaseProto)
+ TabManager.prototype.constructor = TabManager
+
+ // INTERNAL METHODS
+ // ============================
+
+ TabManager.prototype.init = function() {
+ this.registerHandlers()
+ }
+
+ TabManager.prototype.registerHandlers = function() {
+ var $layoutBody = $('#layout-body')
+
+ $layoutBody.on('click', 'li[data-builder-new-tab]', this.proxy(this.onNewTabClick))
+ $layoutBody.on('click', 'div[data-builder-tab]', this.proxy(this.onTabClick))
+ $layoutBody.on('click', 'div[data-builder-close-tab]', this.proxy(this.onTabCloseClick))
+ $layoutBody.on('change livechange', 'ul.tabs > li div.inspector-trigger.tab-control', this.proxy(this.onTabChange))
+ $layoutBody.on('hiding.oc.inspector', 'ul.tabs > li div.inspector-trigger.tab-control', this.proxy(this.onTabInspectorHiding))
+ }
+
+ TabManager.prototype.getTabList = function($tabControl) {
+ return $tabControl.find('> ul.tabs')
+ }
+
+ TabManager.prototype.getPanelList = function($tabControl) {
+ return $tabControl.find('> ul.panels')
+ }
+
+ TabManager.prototype.findTabControl = function($tab) {
+ return $tab.closest('div.tabs')
+ }
+
+ TabManager.prototype.findTabPanel = function($tab) {
+ var $tabControl = this.findTabControl($tab),
+ tabIndex = $tab.index()
+
+ return this.getPanelList($tabControl).find(' > li').eq(tabIndex)
+ }
+
+ TabManager.prototype.findPanelTab = function($panel) {
+ var $tabControl = this.findTabControl($panel),
+ tabIndex = $panel.index()
+
+ return this.getTabList($tabControl).find(' > li').eq(tabIndex)
+ }
+
+ TabManager.prototype.findTabPanel = function($tab) {
+ var $tabControl = this.findTabControl($tab),
+ tabIndex = $tab.index()
+
+ return this.getPanelList($tabControl).find(' > li').eq(tabIndex)
+ }
+
+ TabManager.prototype.findTabForm = function(tab) {
+ return $(tab).closest('form')
+ }
+
+ TabManager.prototype.getGlobalTabsProperties = function(tabsContainer) {
+ var properties = $(tabsContainer).find('.inspector-trigger.tab-control.global [data-inspector-values]').val()
+
+ if (properties.length == 0) {
+ properties = '{}'
+ }
+
+ return $.parseJSON(properties)
+ }
+
+ /*
+ * Returns tab title an element belongs to
+ */
+ TabManager.prototype.getElementTabTitle = function(element) {
+ var $panel = $(element).closest('li.tab-panel'),
+ $tab = this.findPanelTab($panel),
+ properties = $tab.find('[data-inspector-values]').val(),
+ propertiesParsed = $.parseJSON(properties)
+
+ return propertiesParsed.title
+ }
+
+ TabManager.prototype.tabHasControls = function($tab) {
+ return this.findTabPanel($tab).find('ul[data-control-list] li.control:not(.oc-placeholder)').length > 0
+ }
+
+ TabManager.prototype.tabNameExists = function($tabList, name, $ignoreTab) {
+ var tabs = $tabList.get(0).children
+
+ for (var i=0, len = tabs.length; i li[data-builder-new-tab]')
+
+ $('[data-tab-title]', $newTab).text(tabName)
+
+ $newTab.insertBefore($newTabControl)
+ this.getPanelList($tabControl).append(panelTemplate)
+
+ this.gotoTab($newTab)
+ }
+
+ TabManager.prototype.gotoTab = function($tab) {
+ var tabIndex = $tab.index(),
+ $tabControl = this.findTabControl($tab),
+ $tabList = this.getTabList($tabControl),
+ $panelList = this.getPanelList($tabControl)
+
+ $('> li', $tabList).removeClass('active')
+ $tab.addClass('active')
+
+ $('> li', $panelList).removeClass('active')
+ $('> li', $panelList).eq(tabIndex).addClass('active')
+ }
+
+ TabManager.prototype.findInspectorContainer = function($element) {
+ var $containerRoot = $element.closest('[data-inspector-container]')
+
+ return $containerRoot.find('.inspector-container')
+ }
+
+ TabManager.prototype.closeTabInspectors = function($tab, $tabPanel) {
+ if ($tab.find('.inspector-open').length === 0 && $tabPanel.find('.inspector-open').length === 0) {
+ return
+ }
+
+ var $inspectorContainer = this.findInspectorContainer($tab)
+
+ $.oc.foundation.controlUtils.disposeControls($inspectorContainer.get(0))
+ }
+
+ TabManager.prototype.closeTabControlPalette = function($tab, $tabPanel) {
+ if ($tabPanel.find('.control-palette-open').length === 0) {
+ return
+ }
+
+ var $inspectorContainer = this.findInspectorContainer($tab)
+
+ $.oc.foundation.controlUtils.disposeControls($inspectorContainer.get(0))
+ }
+
+ TabManager.prototype.closeTab = function($tab) {
+ var $tabControl = this.findTabControl($tab)
+
+ if (this.tabHasControls($tab)) {
+ if (!confirm($tabControl.data('tabCloseConfirmation'))) {
+ return
+ }
+
+ $tab.trigger('change')
+ }
+
+ var $prevTab = $tab.prev(),
+ $nextTab = $tab.next(),
+ $tabPanel = this.findTabPanel($tab)
+
+ this.closeTabInspectors($tab, $tabPanel)
+ this.closeTabControlPalette($tab, $tabPanel)
+
+ $tab.remove()
+ $tabPanel.remove()
+
+ if ($prevTab.length > 0) {
+ this.gotoTab($prevTab)
+ }
+ else {
+ if ($nextTab.length > 0 && !$nextTab.hasClass('new-tab')) {
+ this.gotoTab($nextTab)
+ }
+ else {
+ this.createNewTab($tabControl)
+ }
+ }
+ }
+
+ TabManager.prototype.updateTabProperties = function($tab) {
+ var properties = $tab.find('[data-inspector-values]').val(),
+ propertiesParsed = $.parseJSON(properties),
+ $form = this.findTabForm($tab),
+ pluginCode = $form.find('input[name=plugin_code]').val()
+
+ $tab.find('[data-tab-title]').attr('data-localization-key', propertiesParsed.title)
+
+ $.oc.builder.dataRegistry.getLocalizationString($form, pluginCode, propertiesParsed.title, function(title){
+ $tab.find('[data-tab-title]').text(title)
+ })
+ }
+
+ // EVENT HANDLERS
+ // ============================
+
+ TabManager.prototype.onNewTabClick = function(ev) {
+ this.createNewTab($(ev.currentTarget).closest('div.tabs'))
+
+ ev.stopPropagation()
+ ev.preventDefault()
+
+ return false
+ }
+
+ TabManager.prototype.onTabClick = function(ev) {
+ this.gotoTab($(ev.currentTarget).closest('li'))
+
+ ev.stopPropagation()
+ ev.preventDefault()
+
+ return false
+ }
+
+ TabManager.prototype.onTabCloseClick = function(ev) {
+ this.closeTab($(ev.currentTarget).closest('li'))
+
+ ev.stopPropagation()
+ ev.preventDefault()
+
+ return false
+ }
+
+ TabManager.prototype.onTabChange = function(ev) {
+ this.updateTabProperties($(ev.currentTarget).closest('li'))
+ }
+
+ TabManager.prototype.onTabInspectorHiding = function(ev, data) {
+ var $tab = $(ev.currentTarget).closest('li'),
+ $tabControl = this.findTabControl($tab),
+ $tabList = this.getTabList($tabControl)
+
+ if (this.tabNameExists($tabList, data.values.title, $tab)) {
+ alert($tabControl.data('tabAlreadyExists'))
+
+ ev.preventDefault()
+ }
+ }
+
+ $(document).ready(function(){
+ // There is a single instance of the tabs manager.
+ $.oc.builder.formbuilder.tabManager = new TabManager()
+ })
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_body.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_body.php
new file mode 100644
index 0000000..c6bd2b8
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_body.php
@@ -0,0 +1,34 @@
+
+
+
+ = $this->makePartial('buildingarea') ?>
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_buildingarea.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_buildingarea.php
new file mode 100644
index 0000000..a15e7a0
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_buildingarea.php
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlbody.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlbody.php
new file mode 100644
index 0000000..1dfe6c9
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlbody.php
@@ -0,0 +1,26 @@
+
+ getPropertyValue($properties, 'label');
+ $comment = $this->getPropertyValue($properties, 'oc.comment');
+
+ // Note - the label and comment elements should not have whitespace in the markup.
+ ?>
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlcontainer.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlcontainer.php
new file mode 100644
index 0000000..eca3d0a
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlcontainer.php
@@ -0,0 +1,26 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controllist.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controllist.php
new file mode 100644
index 0000000..018e50e
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controllist.php
@@ -0,0 +1,40 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlpalette.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlpalette.php
new file mode 100644
index 0000000..05559ab
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlpalette.php
@@ -0,0 +1,33 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlwrapper.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlwrapper.php
new file mode 100644
index 0000000..9256feb
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_controlwrapper.php
@@ -0,0 +1,14 @@
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tab.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tab.php
new file mode 100644
index 0000000..140ed71
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tab.php
@@ -0,0 +1,22 @@
+
+
+
+ = e(trans($title)) ?>
+
+
+
+
+
+
+
+
+
+
+
+
×
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabpanel.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabpanel.php
new file mode 100644
index 0000000..7c2d65c
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabpanel.php
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabs.php b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabs.php
new file mode 100644
index 0000000..4ce12df
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/formbuilder/partials/_tabs.php
@@ -0,0 +1,57 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitem.php b/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitem.php
new file mode 100644
index 0000000..529d122
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitem.php
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitems.php b/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitems.php
new file mode 100644
index 0000000..5661380
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/menueditor/partials/_mainmenuitems.php
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitem.php b/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitem.php
new file mode 100644
index 0000000..673b4d4
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitem.php
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitems.php b/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitems.php
new file mode 100644
index 0000000..24ee8b5
--- /dev/null
+++ b/plugins/rainlab/builder/formwidgets/menueditor/partials/_submenuitems.php
@@ -0,0 +1,17 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/controllerlist/partials/_body.php b/plugins/rainlab/builder/widgets/controllerlist/partials/_body.php
new file mode 100644
index 0000000..46306d3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/controllerlist/partials/_body.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/controllerlist/partials/_controller-list.php b/plugins/rainlab/builder/widgets/controllerlist/partials/_controller-list.php
new file mode 100644
index 0000000..c99bfa9
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/controllerlist/partials/_controller-list.php
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/controllerlist/partials/_items.php b/plugins/rainlab/builder/widgets/controllerlist/partials/_items.php
new file mode 100644
index 0000000..c7e04ff
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/controllerlist/partials/_items.php
@@ -0,0 +1,20 @@
+
+
+
diff --git a/plugins/rainlab/builder/widgets/controllerlist/partials/_toolbar.php b/plugins/rainlab/builder/widgets/controllerlist/partials/_toolbar.php
new file mode 100644
index 0000000..5ce9f57
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/controllerlist/partials/_toolbar.php
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/controllerlist/partials/_widget-contents.php b/plugins/rainlab/builder/widgets/controllerlist/partials/_widget-contents.php
new file mode 100644
index 0000000..14affab
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/controllerlist/partials/_widget-contents.php
@@ -0,0 +1,28 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/databasetablelist/partials/_body.php b/plugins/rainlab/builder/widgets/databasetablelist/partials/_body.php
new file mode 100644
index 0000000..46306d3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/databasetablelist/partials/_body.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/databasetablelist/partials/_items.php b/plugins/rainlab/builder/widgets/databasetablelist/partials/_items.php
new file mode 100644
index 0000000..90c821f
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/databasetablelist/partials/_items.php
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/databasetablelist/partials/_table-list.php b/plugins/rainlab/builder/widgets/databasetablelist/partials/_table-list.php
new file mode 100644
index 0000000..0f711cd
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/databasetablelist/partials/_table-list.php
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/databasetablelist/partials/_toolbar.php b/plugins/rainlab/builder/widgets/databasetablelist/partials/_toolbar.php
new file mode 100644
index 0000000..b268611
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/databasetablelist/partials/_toolbar.php
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/databasetablelist/partials/_widget-contents.php b/plugins/rainlab/builder/widgets/databasetablelist/partials/_widget-contents.php
new file mode 100644
index 0000000..e529225
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/databasetablelist/partials/_widget-contents.php
@@ -0,0 +1,28 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-form-controller.php b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-form-controller.php
new file mode 100644
index 0000000..c0b7bd4
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-form-controller.php
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-import-export-controller.php b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-import-export-controller.php
new file mode 100644
index 0000000..bee9d63
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-import-export-controller.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-list-controller.php b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-list-controller.php
new file mode 100644
index 0000000..e2b1bd2
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-list-controller.php
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-unknown.php b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-unknown.php
new file mode 100644
index 0000000..1a0e7af
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultbehaviordesigntimeprovider/partials/_behavior-unknown.php
@@ -0,0 +1 @@
+Unknown behavior
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-entry.php b/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-entry.php
new file mode 100644
index 0000000..f900cf3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-entry.php
@@ -0,0 +1,51 @@
+
+
diff --git a/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-unknown.php b/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-unknown.php
new file mode 100644
index 0000000..abddc94
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultblueprintdesigntimeprovider/partials/_blueprint-unknown.php
@@ -0,0 +1 @@
+Unknown blueprint
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-balloon-selector.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-balloon-selector.php
new file mode 100644
index 0000000..f834507
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-balloon-selector.php
@@ -0,0 +1,16 @@
+
+
+
+
= e(trans($option)) ?>
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkbox.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkbox.php
new file mode 100644
index 0000000..0b68995
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkbox.php
@@ -0,0 +1,9 @@
+getPropertyValue($properties, 'label');
+ $comment = $this->getPropertyValue($properties, 'oc.comment');
+?>
+
+
+
= e(trans($label)) ?>
+
= e(trans($comment)) ?>
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkboxlist.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkboxlist.php
new file mode 100644
index 0000000..ef4c056
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-checkboxlist.php
@@ -0,0 +1,16 @@
+
+
+
+
= e(trans($option)) ?>
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-codeeditor.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-codeeditor.php
new file mode 100644
index 0000000..297f2e9
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-codeeditor.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-colorpicker.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-colorpicker.php
new file mode 100644
index 0000000..5ed57b6
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-colorpicker.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datatable.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datatable.php
new file mode 100644
index 0000000..aba343d
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datatable.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datepicker.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datepicker.php
new file mode 100644
index 0000000..4cf196f
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-datepicker.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-dropdown.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-dropdown.php
new file mode 100644
index 0000000..9b0fa20
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-dropdown.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-email.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-email.php
new file mode 100644
index 0000000..557f21c
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-email.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-fileupload.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-fileupload.php
new file mode 100644
index 0000000..4988ad8
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-fileupload.php
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-hint.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-hint.php
new file mode 100644
index 0000000..fcd7858
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-hint.php
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-markdown.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-markdown.php
new file mode 100644
index 0000000..404c61c
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-markdown.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-mediafinder.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-mediafinder.php
new file mode 100644
index 0000000..14d7ec8
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-mediafinder.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-nestedform-static.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-nestedform-static.php
new file mode 100644
index 0000000..5f2e08a
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-nestedform-static.php
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-pagefinder.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-pagefinder.php
new file mode 100644
index 0000000..d6930b6
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-pagefinder.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-password.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-password.php
new file mode 100644
index 0000000..d684156
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-password.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-radio.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-radio.php
new file mode 100644
index 0000000..6685f86
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-radio.php
@@ -0,0 +1,16 @@
+
+
+
+
= e(trans($option)) ?>
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-recordfinder.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-recordfinder.php
new file mode 100644
index 0000000..85f0d48
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-recordfinder.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-relation.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-relation.php
new file mode 100644
index 0000000..4bbfc9d
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-relation.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-repeater-static.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-repeater-static.php
new file mode 100644
index 0000000..5f2e08a
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-repeater-static.php
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-richeditor.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-richeditor.php
new file mode 100644
index 0000000..e56619b
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-richeditor.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-ruler.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-ruler.php
new file mode 100644
index 0000000..0fd8f18
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-ruler.php
@@ -0,0 +1 @@
+
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-section.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-section.php
new file mode 100644
index 0000000..a9d75b3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-section.php
@@ -0,0 +1,9 @@
+getPropertyValue($properties, 'label');
+ $comment =$this->getPropertyValue($properties, 'oc.comment');
+?>
+
+
+
= e(trans($label)) ?>
+
= e(trans($comment)) ?>
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-sensitive.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-sensitive.php
new file mode 100644
index 0000000..72fa918
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-sensitive.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-taglist.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-taglist.php
new file mode 100644
index 0000000..c2a2281
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-taglist.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-textarea.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-textarea.php
new file mode 100644
index 0000000..c9fd1f0
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-textarea.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-unknowncontrol.php b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-unknowncontrol.php
new file mode 100644
index 0000000..fd2af48
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/defaultcontroldesigntimeprovider/partials/_control-unknowncontrol.php
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/languagelist/partials/_body.php b/plugins/rainlab/builder/widgets/languagelist/partials/_body.php
new file mode 100644
index 0000000..46306d3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/languagelist/partials/_body.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/languagelist/partials/_items.php b/plugins/rainlab/builder/widgets/languagelist/partials/_items.php
new file mode 100644
index 0000000..cb2e0b4
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/languagelist/partials/_items.php
@@ -0,0 +1,20 @@
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/languagelist/partials/_language-list.php b/plugins/rainlab/builder/widgets/languagelist/partials/_language-list.php
new file mode 100644
index 0000000..ef3df47
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/languagelist/partials/_language-list.php
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/languagelist/partials/_toolbar.php b/plugins/rainlab/builder/widgets/languagelist/partials/_toolbar.php
new file mode 100644
index 0000000..d8b409b
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/languagelist/partials/_toolbar.php
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/languagelist/partials/_widget-contents.php b/plugins/rainlab/builder/widgets/languagelist/partials/_widget-contents.php
new file mode 100644
index 0000000..2a9b2f9
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/languagelist/partials/_widget-contents.php
@@ -0,0 +1,28 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/modellist/partials/_body.php b/plugins/rainlab/builder/widgets/modellist/partials/_body.php
new file mode 100644
index 0000000..46306d3
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/modellist/partials/_body.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/modellist/partials/_items.php b/plugins/rainlab/builder/widgets/modellist/partials/_items.php
new file mode 100644
index 0000000..b2196c4
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/modellist/partials/_items.php
@@ -0,0 +1,94 @@
+
+
diff --git a/plugins/rainlab/builder/widgets/modellist/partials/_toolbar.php b/plugins/rainlab/builder/widgets/modellist/partials/_toolbar.php
new file mode 100644
index 0000000..346057f
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/modellist/partials/_toolbar.php
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/modellist/partials/_widget-contents.php b/plugins/rainlab/builder/widgets/modellist/partials/_widget-contents.php
new file mode 100644
index 0000000..f87cd74
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/modellist/partials/_widget-contents.php
@@ -0,0 +1,28 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/pluginlist/partials/_body.php b/plugins/rainlab/builder/widgets/pluginlist/partials/_body.php
new file mode 100644
index 0000000..0040fb2
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/pluginlist/partials/_body.php
@@ -0,0 +1,8 @@
+= $this->makePartial('toolbar') ?>
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/pluginlist/partials/_items.php b/plugins/rainlab/builder/widgets/pluginlist/partials/_items.php
new file mode 100644
index 0000000..b304288
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/pluginlist/partials/_items.php
@@ -0,0 +1,35 @@
+
+ getActivePluginCode(); ?>
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/pluginlist/partials/_plugin-list.php b/plugins/rainlab/builder/widgets/pluginlist/partials/_plugin-list.php
new file mode 100644
index 0000000..eab0976
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/pluginlist/partials/_plugin-list.php
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar-buttons.php b/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar-buttons.php
new file mode 100644
index 0000000..d2226d8
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar-buttons.php
@@ -0,0 +1,14 @@
+
+
+getFilterMode(); ?>
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar.php b/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar.php
new file mode 100644
index 0000000..69d6269
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/pluginlist/partials/_toolbar.php
@@ -0,0 +1,22 @@
+
+
+
+
+ = $this->makePartial("toolbar-buttons") ?>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/versionlist/partials/_body.php b/plugins/rainlab/builder/widgets/versionlist/partials/_body.php
new file mode 100644
index 0000000..b39cdd0
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/versionlist/partials/_body.php
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/versionlist/partials/_items.php b/plugins/rainlab/builder/widgets/versionlist/partials/_items.php
new file mode 100644
index 0000000..940a482
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/versionlist/partials/_items.php
@@ -0,0 +1,35 @@
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/builder/widgets/versionlist/partials/_widget-contents.php b/plugins/rainlab/builder/widgets/versionlist/partials/_widget-contents.php
new file mode 100644
index 0000000..a2813ef
--- /dev/null
+++ b/plugins/rainlab/builder/widgets/versionlist/partials/_widget-contents.php
@@ -0,0 +1,28 @@
+
+
diff --git a/plugins/rainlab/translate/.gitignore b/plugins/rainlab/translate/.gitignore
new file mode 100644
index 0000000..4b53aba
--- /dev/null
+++ b/plugins/rainlab/translate/.gitignore
@@ -0,0 +1,4 @@
+/vendor
+composer.lock
+.DS_Store
+.phpunit.result.cache
diff --git a/plugins/rainlab/translate/LICENCE.md b/plugins/rainlab/translate/LICENCE.md
new file mode 100644
index 0000000..e49b459
--- /dev/null
+++ b/plugins/rainlab/translate/LICENCE.md
@@ -0,0 +1,21 @@
+# MIT license
+
+Copyright (c) 2014-2022 Responsiv Pty Ltd, October CMS
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/rainlab/translate/Plugin.php b/plugins/rainlab/translate/Plugin.php
new file mode 100644
index 0000000..ec57883
--- /dev/null
+++ b/plugins/rainlab/translate/Plugin.php
@@ -0,0 +1,381 @@
+ 'rainlab.translate::lang.plugin.name',
+ 'description' => 'rainlab.translate::lang.plugin.description',
+ 'author' => 'Alexey Bobkov, Samuel Georges',
+ 'icon' => 'icon-language',
+ 'homepage' => 'https://github.com/rainlab/translate-plugin'
+ ];
+ }
+
+ /**
+ * register the plugin
+ */
+ public function register()
+ {
+ // Load localized version of mail templates (akin to localized CMS content files)
+ Event::listen('mailer.beforeAddContent', function ($mailer, $message, $view, $data, $raw, $plain) {
+ return EventRegistry::instance()->findLocalizedMailViewContent($mailer, $message, $view, $data, $raw, $plain);
+ }, 1);
+
+ // Defer event with low priority to let others contribute before this registers.
+ Event::listen('backend.form.extendFieldsBefore', function($widget) {
+ EventRegistry::instance()->registerFormFieldReplacements($widget);
+ }, -1);
+
+ // Handle translated page URLs
+ Page::extend(function($model) {
+ if (!$model->propertyExists('translatable')) {
+ $model->addDynamicProperty('translatable', []);
+ }
+ $model->translatable = array_merge($model->translatable, ['title', 'description', 'meta_title', 'meta_description']);
+ if (!$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatablePageUrl::class)) {
+ $model->extendClassWith(\RainLab\Translate\Behaviors\TranslatablePageUrl::class);
+ }
+ if (!$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatablePage::class)) {
+ $model->extendClassWith(\RainLab\Translate\Behaviors\TranslatablePage::class);
+ }
+ });
+
+ // Extension logic for October CMS v1.0
+ if (!class_exists('System')) {
+ $this->extendLegacyPlatform();
+ }
+ // Extension logic for October CMS v2.0
+ else {
+ Event::listen('cms.theme.createThemeDataModel', function($attributes) {
+ return new \RainLab\Translate\Models\MLThemeData($attributes);
+ });
+
+ Event::listen('cms.template.getTemplateToolbarSettingsButtons', function($extension, $dataHolder) {
+ if ($dataHolder->templateType === 'page') {
+ EventRegistry::instance()->extendEditorPageToolbar($dataHolder);
+ }
+ });
+ }
+
+ // Register console commands
+ $this->registerConsoleCommand('translate.scan', \Rainlab\Translate\Console\ScanCommand::class);
+
+ // Register asset bundles
+ $this->registerAssetBundles();
+ }
+
+ /**
+ * extendLegacyPlatform will add the legacy features expected in v1.0
+ */
+ protected function extendLegacyPlatform()
+ {
+ // Adds translation support to file models
+ File::extend(function ($model) {
+ if (!$model->propertyExists('translatable')) {
+ $model->addDynamicProperty('translatable', []);
+ }
+ $model->translatable = array_merge($model->translatable, ['title', 'description']);
+ if (!$model->isClassExtendedWith(\October\Rain\Database\Behaviors\Purgeable::class)) {
+ $model->extendClassWith(\October\Rain\Database\Behaviors\Purgeable::class);
+ }
+ if (!$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatableModel::class)) {
+ $model->extendClassWith(\RainLab\Translate\Behaviors\TranslatableModel::class);
+ }
+ });
+
+ // Adds translation support to theme settings
+ ThemeData::extend(static function ($model) {
+ if (!$model->propertyExists('translatable')) {
+ $model->addDynamicProperty('translatable', []);
+ }
+
+ if (!$model->isClassExtendedWith(\October\Rain\Database\Behaviors\Purgeable::class)) {
+ $model->extendClassWith(\October\Rain\Database\Behaviors\Purgeable::class);
+ }
+ if (!$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatableModel::class)) {
+ $model->extendClassWith(\RainLab\Translate\Behaviors\TranslatableModel::class);
+ }
+
+ $model->bindEvent('model.afterFetch', static function() use ($model) {
+ foreach ($model->getFormFields() as $id => $field) {
+ if (!empty($field['translatable'])) {
+ $model->translatable[] = $id;
+ }
+ }
+ });
+ });
+ }
+
+ /**
+ * boot the plugin
+ */
+ public function boot()
+ {
+ // Set the page context for translation caching with high priority.
+ Event::listen('cms.page.init', function($controller, $page) {
+ EventRegistry::instance()->setMessageContext($page);
+ }, 100);
+
+ // Populate MenuItem properties with localized values if available
+ Event::listen('pages.menu.referencesGenerated', function (&$items) {
+ $locale = App::getLocale();
+ $iterator = function ($menuItems) use (&$iterator, $locale) {
+ $result = [];
+ foreach ($menuItems as $item) {
+ $localeFields = array_get($item->viewBag, "locale.$locale", []);
+ foreach ($localeFields as $fieldName => $fieldValue) {
+ if ($fieldValue) {
+ $item->$fieldName = $fieldValue;
+ }
+ }
+ if ($item->items) {
+ $item->items = $iterator($item->items);
+ }
+ $result[] = $item;
+ }
+ return $result;
+ };
+ $items = $iterator($items);
+ });
+
+ // Import messages defined by the theme
+ Event::listen('cms.theme.setActiveTheme', function($code) {
+ EventRegistry::instance()->importMessagesFromTheme($code);
+ });
+
+ // Adds language suffixes to content files.
+ Event::listen('cms.page.beforeRenderContent', function($controller, $fileName) {
+ return EventRegistry::instance()
+ ->findTranslatedContentFile($controller, $fileName)
+ ;
+ });
+
+ // Prune localized content files from template list
+ Event::listen('pages.content.templateList', function($widget, $templates) {
+ return EventRegistry::instance()
+ ->pruneTranslatedContentTemplates($templates)
+ ;
+ });
+
+ // Look at session for locale using middleware
+ \Cms\Classes\CmsController::extend(function($controller) {
+ $controller->middleware(\RainLab\Translate\Classes\LocaleMiddleware::class);
+ });
+
+ // Append current locale to static page's cache keys
+ $modifyKey = function (&$key) {
+ $key = $key . '-' . Lang::getLocale();
+ };
+ Event::listen('pages.router.getCacheKey', $modifyKey);
+ Event::listen('pages.page.getMenuCacheKey', $modifyKey);
+ Event::listen('pages.snippet.getMapCacheKey', $modifyKey);
+ Event::listen('pages.snippet.getPartialMapCacheKey', $modifyKey);
+
+ if (class_exists('\RainLab\Pages\Classes\SnippetManager')) {
+ $handler = function ($controller, $template, $type) {
+ if (!$template->methodExists('getDirtyLocales')) {
+ return;
+ }
+
+ // Get the locales that have changed
+ $dirtyLocales = $template->getDirtyLocales();
+
+ if (!empty($dirtyLocales)) {
+ $currentLocale = Lang::getLocale();
+
+ foreach ($dirtyLocales as $locale) {
+ if (!$template->isTranslateDirty(null, $locale)) {
+ continue;
+ }
+
+ // Clear the RainLab.Pages caches for each dirty locale
+ App::setLocale($locale);
+ \RainLab\Pages\Plugin::clearCache();
+ }
+
+ // Restore the original locale for this request
+ App::setLocale($currentLocale);
+ }
+ };
+
+ Event::listen('cms.template.save', $handler);
+ Event::listen('pages.object.save', $handler);
+ }
+ }
+
+ /**
+ * registerComponents
+ */
+ public function registerComponents()
+ {
+ return [
+ \RainLab\Translate\Components\LocalePicker::class => 'localePicker',
+ \RainLab\Translate\Components\AlternateHrefLangElements::class => 'alternateHrefLangElements'
+ ];
+ }
+
+ /**
+ * registerPermissions
+ */
+ public function registerPermissions()
+ {
+ return [
+ 'rainlab.translate.manage_locales' => [
+ 'tab' => 'rainlab.translate::lang.plugin.tab',
+ 'label' => 'rainlab.translate::lang.plugin.manage_locales'
+ ],
+ 'rainlab.translate.manage_messages' => [
+ 'tab' => 'rainlab.translate::lang.plugin.tab',
+ 'label' => 'rainlab.translate::lang.plugin.manage_messages'
+ ]
+ ];
+ }
+
+ /**
+ * registerSettings
+ */
+ public function registerSettings()
+ {
+ return [
+ 'locales' => [
+ 'label' => 'rainlab.translate::lang.locale.title',
+ 'description' => 'rainlab.translate::lang.plugin.description',
+ 'icon' => 'icon-language',
+ 'url' => Backend::url('rainlab/translate/locales'),
+ 'order' => 550,
+ 'category' => 'rainlab.translate::lang.plugin.name',
+ 'permissions' => ['rainlab.translate.manage_locales'],
+ 'keywords' => 'translate',
+ ],
+ 'messages' => [
+ 'label' => 'rainlab.translate::lang.messages.title',
+ 'description' => 'rainlab.translate::lang.messages.description',
+ 'icon' => 'icon-list-alt',
+ 'url' => Backend::url('rainlab/translate/messages'),
+ 'order' => 551,
+ 'category' => 'rainlab.translate::lang.plugin.name',
+ 'permissions' => ['rainlab.translate.manage_messages'],
+ 'keywords' => 'translate',
+ ]
+ ];
+ }
+
+ /**
+ * registerMarkupTags for Twig
+ * @return array
+ */
+ public function registerMarkupTags()
+ {
+ return [
+ 'filters' => [
+ '_' => [$this, 'translateString'],
+ '__' => [$this, 'translatePlural'],
+ 'transRaw' => [$this, 'translateRawString'],
+ 'transRawPlural' => [$this, 'translateRawPlural'],
+ 'localeUrl' => [$this, 'localeUrl'],
+ ]
+ ];
+ }
+
+ /**
+ * registerFormWidgets for multi-lingual
+ */
+ public function registerFormWidgets()
+ {
+ $mediaFinderClass = class_exists('System')
+ ? \RainLab\Translate\FormWidgets\MLMediaFinderv2::class
+ : \RainLab\Translate\FormWidgets\MLMediaFinder::class;
+
+ return [
+ \RainLab\Translate\FormWidgets\MLText::class => 'mltext',
+ \RainLab\Translate\FormWidgets\MLTextarea::class => 'mltextarea',
+ \RainLab\Translate\FormWidgets\MLRichEditor::class => 'mlricheditor',
+ \RainLab\Translate\FormWidgets\MLMarkdownEditor::class => 'mlmarkdowneditor',
+ \RainLab\Translate\FormWidgets\MLRepeater::class => 'mlrepeater',
+ \RainLab\Translate\FormWidgets\MLNestedForm::class => 'mlnestedform',
+ $mediaFinderClass => 'mlmediafinder',
+ ];
+ }
+
+ /**
+ * registerAssetBundles for compilation
+ */
+ protected function registerAssetBundles()
+ {
+ CombineAssets::registerCallback(function ($combiner) {
+ $combiner->registerBundle('$/rainlab/translate/assets/less/messages.less');
+ $combiner->registerBundle('$/rainlab/translate/assets/less/multilingual.less');
+ });
+ }
+
+ /**
+ * localeUrl builds a localized URL
+ */
+ public function localeUrl($url, $locale)
+ {
+ $translator = Translator::instance();
+
+ $parts = parse_url($url);
+
+ $path = array_get($parts, 'path');
+
+ return http_build_url($parts, [
+ 'path' => '/' . $translator->getPathInLocale($path, $locale)
+ ]);
+ }
+
+ /**
+ * translateString
+ */
+ public function translateString($string, $params = [], $locale = null)
+ {
+ return Message::trans($string, $params, $locale);
+ }
+
+ /**
+ * translatePlural
+ */
+ public function translatePlural($string, $count = 0, $params = [], $locale = null)
+ {
+ return Lang::choice(Message::trans($string, $params, $locale), $count, $params);
+ }
+
+ /**
+ * translateRawString
+ */
+ public function translateRawString($string, $params = [], $locale = null)
+ {
+ return Message::transRaw($string, $params, $locale);
+ }
+
+ /**
+ * translateRawPlural
+ */
+ public function translateRawPlural($string, $count = 0, $params = [], $locale = null)
+ {
+ return Lang::choice(Message::transRaw($string, $params, $locale), $count, $params);
+ }
+}
diff --git a/plugins/rainlab/translate/README.md b/plugins/rainlab/translate/README.md
new file mode 100644
index 0000000..6522717
--- /dev/null
+++ b/plugins/rainlab/translate/README.md
@@ -0,0 +1,394 @@
+# Translation plugin
+
+Enables multi-lingual sites.
+
+## Selecting a language
+
+Different languages can be set up in the back-end area, with a single default language selected. This activates the use of the language on the front-end and in the back-end UI.
+
+A visitor can select a language by prefixing the language code to the URL, this is then stored in the user's session as their chosen language. For example:
+
+* `http://website/ru/` will display the site in Russian
+* `http://website/fr/` will display the site in French
+* `http://website/` will display the site in the default language or the user's chosen language.
+
+## Language Picker Component
+
+A visitor can select their chosen language using the `LocalePicker` component. This component will display a simple dropdown that changes the page language depending on the selection.
+
+ title = "Home"
+ url = "/"
+
+ [localePicker]
+ ==
+
+
{{ 'Please select your language:'|_ }}
+ {% component 'localePicker' %}
+
+If translated, the text above will appear as whatever language is selected by the user. The dropdown is very basic and is intended to be restyled. A simpler example might be:
+
+ [...]
+ ==
+
+
+
+## Message translation
+
+Message or string translation is the conversion of adhoc strings used throughout the site. A message can be translated with parameters.
+
+ {{ 'site.name'|_ }}
+
+ {{ 'Welcome to our website!'|_ }}
+
+ {{ 'Hello :name!'|_({ name: 'Friend' }) }}
+
+A message can also be translated for a choice usage.
+
+ {{ 'There are no apples|There are :number applies!'|__(2, { number: 'two' }) }}
+
+Or you set a locale manually by passing a second argument.
+
+ {{ 'this is always english'|_({}, 'en') }}
+
+Themes can provide default values for these messages by defining a `translate` key in the `theme.yaml` file, located in the theme directory.
+
+ name: My Theme
+ # [...]
+
+ translate:
+ en:
+ site.name: 'My Website'
+ nav.home: 'Home'
+ nav.video: 'Video'
+ title.home: 'Welcome Home'
+ title.video: 'Screencast Video'
+
+You may also define the translations in a separate file, where the path is relative to the theme. The following definition will source the default messages from the file **config/lang.yaml** inside the theme.
+
+ name: My Theme
+ # [...]
+
+ translate: config/lang.yaml
+
+This is an example of **config/lang.yaml** file with two languages:
+
+ en:
+ site.name: 'My Website'
+ nav.home: 'Home'
+ nav.video: 'Video'
+ title.home: 'Welcome Home'
+ hr:
+ site.name: 'Moje web stranice'
+ nav.home: 'Početna'
+ nav.video: 'Video'
+ title.home: 'Dobrodošli'
+
+You may also define the translations in a separate file per locale, where the path is relative to the theme. The following definition will source the default messages from the file **config/lang-en.yaml** inside the theme for the english locale and from the file **config/lang-fr.yaml** for the french locale.
+
+ name: My Theme
+ # [...]
+
+ translate:
+ en: config/lang-en.yaml
+ fr: config/lang-fr.yaml
+
+This is an example for the **config/lang-en.yaml** file:
+
+ site.name: 'My Website'
+ nav.home: 'Home'
+ nav.video: 'Video'
+ title.home: 'Welcome Home'
+
+In order to make these default values reflected to your frontend site, go to **Settings -> Translate messages** in the backend and hit **Scan for messages**. They will also be loaded automatically when the theme is activated.
+
+The same operation can be performed with the `translate:scan` artisan command. It may be worth including it in a deployment script to automatically fetch updated messages:
+
+ php artisan translate:scan
+
+Add the `--purge` option to clear old messages first:
+
+ php artisan translate:scan --purge
+
+## Content & mail template translation
+
+This plugin activates a feature in the CMS that allows content & mail template files to use language suffixes, for example:
+
+* **welcome.htm** will contain the content or mail template in the default language.
+* **welcome-ru.htm** will contain the content or mail template in Russian.
+* **welcome-fr.htm** will contain the content or mail template in French.
+
+## Model translation
+
+Models can have their attributes translated by using the `RainLab.Translate.Behaviors.TranslatableModel` behavior and specifying which attributes to translate in the class.
+
+ class User
+ {
+ public $implement = ['RainLab.Translate.Behaviors.TranslatableModel'];
+
+ public $translatable = ['name'];
+ }
+
+The attribute will then contain the default language value and other language code values can be created by using the `translateContext()` method.
+
+ $user = User::first();
+
+ // Outputs the name in the default language
+ echo $user->name;
+
+ $user->translateContext('fr');
+
+ // Outputs the name in French
+ echo $user->name;
+
+You may use the same process for setting values.
+
+ $user = User::first();
+
+ // Sets the name in the default language
+ $user->name = 'English';
+
+ $user->translateContext('fr');
+
+ // Sets the name in French
+ $user->name = 'Anglais';
+
+The `lang()` method is a shorthand version of `translateContext()` and is also chainable.
+
+ // Outputs the name in French
+ echo $user->lang('fr')->name;
+
+This can be useful inside a Twig template.
+
+ {{ user.lang('fr').name }}
+
+There are ways to get and set attributes without changing the context.
+
+ // Gets a single translated attribute for a language
+ $user->getAttributeTranslated('name', 'fr');
+
+ // Sets a single translated attribute for a language
+ $user->setAttributeTranslated('name', 'Jean-Claude', 'fr');
+
+## Theme data translation
+
+It is also possible to translate theme customisation options. Just mark your form fields with `translatable` property and the plugin will take care about everything else:
+
+ tabs:
+ fields:
+ website_name:
+ tab: Info
+ label: Website Name
+ type: text
+ default: Your website name
+ translatable: true
+
+## Fallback attribute values
+
+By default, untranslated attributes will fall back to the default locale. This behavior can be disabled by calling the `noFallbackLocale` method.
+
+ $user = User::first();
+
+ $user->noFallbackLocale()->lang('fr');
+
+ // Returns NULL if there is no French translation
+ $user->name;
+
+## Indexed attributes
+
+Translatable model attributes can also be declared as an index by passing the `$transatable` attribute value as an array. The first value is the attribute name, the other values represent options, in this case setting the option `index` to `true`.
+
+ public $translatable = [
+ 'name',
+ ['slug', 'index' => true]
+ ];
+
+Once an attribute is indexed, you may use the `transWhere` method to apply a basic query to the model.
+
+ Post::transWhere('slug', 'hello-world')->first();
+
+The `transWhere` method accepts a third argument to explicitly pass a locale value, otherwise it will be detected from the environment.
+
+ Post::transWhere('slug', 'hello-world', 'en')->first();
+
+## URL translation
+
+Pages in the CMS support translating the URL property. Assuming you have 3 languages set up:
+
+- en: English
+- fr: French
+- ru: Russian
+
+There is a page with the following content:
+
+ url = "/contact"
+
+ [viewBag]
+ localeUrl[ru] = "/контакт"
+ ==
+
Page content
+
+The word "Contact" in French is the same so a translated URL is not given, or needed. If the page has no URL override specified, then the default URL will be used. Pages will not be duplicated for a given language.
+
+- /fr/contact - Page in French
+- /en/contact - Page in English
+- /ru/контакт - Page in Russian
+- /ru/contact - 404
+
+## URL parameter translation
+
+It's possible to translate URL parameters by listening to the `translate.localePicker.translateParams` event, which is fired when switching languages.
+
+ Event::listen('translate.localePicker.translateParams', function($page, $params, $oldLocale, $newLocale) {
+ if ($page->baseFileName == 'your-page-filename') {
+ return YourModel::translateParams($params, $oldLocale, $newLocale);
+ }
+ });
+
+In YourModel, one possible implementation might look like this:
+
+ public static function translateParams($params, $oldLocale, $newLocale) {
+ $newParams = $params;
+ foreach ($params as $paramName => $paramValue) {
+ $records = self::transWhere($paramName, $paramValue, $oldLocale)->first();
+ if ($records) {
+ $records->translateContext($newLocale);
+ $newParams[$paramName] = $records->$paramName;
+ }
+ }
+ return $newParams;
+ }
+
+## Query string translation
+
+It's possible to translate query string parameters by listening to the `translate.localePicker.translateQuery` event, which is fired when switching languages.
+
+ Event::listen('translate.localePicker.translateQuery', function($page, $params, $oldLocale, $newLocale) {
+ if ($page->baseFileName == 'your-page-filename') {
+ return YourModel::translateParams($params, $oldLocale, $newLocale);
+ }
+ });
+
+For a possible implementation of the `YourModel::translateParams` method look at the example under `URL parameter translation` from above.
+
+## Extend theme scan
+
+ Event::listen('rainlab.translate.themeScanner.afterScan', function (ThemeScanner $scanner) {
+ ...
+ });
+
+## Settings model translation
+
+It's possible to translate your settings model like any other model. To retrieve translated values use:
+
+ Settings::instance()->getAttributeTranslated('your_attribute_name');
+
+## Conditionally extending plugins
+
+#### Models
+
+It is possible to conditionally extend a plugin's models to support translation by placing an `@` symbol before the behavior definition. This is a soft implement will only use `TranslatableModel` if the Translate plugin is installed, otherwise it will not cause any errors.
+
+ /**
+ * Blog Post Model
+ */
+ class Post extends Model
+ {
+
+ [...]
+
+ /**
+ * Softly implement the TranslatableModel behavior.
+ */
+ public $implement = ['@RainLab.Translate.Behaviors.TranslatableModel'];
+
+ /**
+ * @var array Attributes that support translation, if available.
+ */
+ public $translatable = ['title'];
+
+ [...]
+
+ }
+
+The back-end forms will automatically detect the presence of translatable fields and replace their controls for multilingual equivalents.
+
+#### Messages
+
+Since the Twig filter will not be available all the time, we can pipe them to the native Laravel translation methods instead. This ensures translated messages will always work on the front end.
+
+ /**
+ * Register new Twig variables
+ * @return array
+ */
+ public function registerMarkupTags()
+ {
+ // Check the translate plugin is installed
+ if (!class_exists('RainLab\Translate\Behaviors\TranslatableModel'))
+ return;
+
+ return [
+ 'filters' => [
+ '_' => ['Lang', 'get'],
+ '__' => ['Lang', 'choice'],
+ ]
+ ];
+ }
+
+# User Interface
+
+#### Switching locales
+
+Users can switch between locales by clicking on the locale indicator on the right hand side of the Multi-language input. By holding the CMD / CTRL key all Multi-language Input fields will switch to the selected locale.
+
+## Integration without jQuery and October Framework files
+
+It is possible to use the front-end language switcher without using jQuery or the OctoberCMS AJAX Framework by making the AJAX API request yourself manually. The following is an example of how to do that.
+
+ document.querySelector('#languageSelect').addEventListener('change', function () {
+ const details = {
+ _session_key: document.querySelector('input[name="_session_key"]').value,
+ _token: document.querySelector('input[name="_token"]').value,
+ locale: this.value
+ }
+
+ let formBody = []
+
+ for (var property in details) {
+ let encodedKey = encodeURIComponent(property)
+ let encodedValue = encodeURIComponent(details[property])
+ formBody.push(encodedKey + '=' + encodedValue)
+ }
+
+ formBody = formBody.join('&')
+
+ fetch(location.href + '/', {
+ method: 'POST',
+ body: formBody,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ 'X-OCTOBER-REQUEST-HANDLER': 'onSwitchLocale',
+ 'X-OCTOBER-REQUEST-PARTIALS': '',
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+ })
+ .then(res => res.json())
+ .then(res => window.location.replace(res.X_OCTOBER_REDIRECT))
+ .catch(err => console.log(err))
+ })
+
+The HTML:
+
+ {{ form_open() }}
+
+ {{ form_close() }}
diff --git a/plugins/rainlab/translate/assets/css/messages.css b/plugins/rainlab/translate/assets/css/messages.css
new file mode 100644
index 0000000..13b13fa
--- /dev/null
+++ b/plugins/rainlab/translate/assets/css/messages.css
@@ -0,0 +1,17 @@
+#messagesContainer{padding-top:20px}
+.translate-messages{position:relative;margin-top:-20px}
+.translate-messages .dropdown-button-placeholder{display:block;width:1px;height:20px}
+.translate-messages .dropdown-to,
+.translate-messages .dropdown-from{position:absolute;top:57px}
+.translate-messages .dropdown-to{left:50%}
+.translate-messages .dropdown-from{left:0}
+.translate-messages .no-other-languages{text-align:center;padding:5px}
+.translate-messages .header-language,
+.translate-messages .header-swap-languages,
+.translate-messages .header-hide-translated{line-height:27px}
+.translate-messages .header-language{float:left;cursor:pointer}
+.translate-messages .header-language span.is-default{text-transform:none}
+.translate-messages .header-swap-languages{margin-right:10px;float:right;font-size:16px;cursor:pointer}
+.translate-messages .header-hide-translated{float:right;text-align:right}
+.translate-messages .header-hide-translated label{font-weight:normal;margin-bottom:0;margin-right:10px;font-size:12px;white-space:normal}
+.translate-messages .header-hide-translated label:before{top:5px;left:0}
\ No newline at end of file
diff --git a/plugins/rainlab/translate/assets/css/multilingual-v1.css b/plugins/rainlab/translate/assets/css/multilingual-v1.css
new file mode 100644
index 0000000..aa2ed8b
--- /dev/null
+++ b/plugins/rainlab/translate/assets/css/multilingual-v1.css
@@ -0,0 +1,13 @@
+/*
+ * Legacy styles for October v1.0
+ */
+
+/* Fancy layout */
+
+.fancy-layout .form-tabless-fields .field-multilingual .ml-btn {
+ color: rgba(255,255,255,0.8);
+}
+
+.fancy-layout .form-tabless-fields .field-multilingual .ml-btn:hover {
+ color: #fff;
+}
diff --git a/plugins/rainlab/translate/assets/css/multilingual.css b/plugins/rainlab/translate/assets/css/multilingual.css
new file mode 100644
index 0000000..278a925
--- /dev/null
+++ b/plugins/rainlab/translate/assets/css/multilingual.css
@@ -0,0 +1,23 @@
+.field-multilingual{position:relative}
+.field-multilingual .ml-btn{background:transparent;position:absolute;color:#7b7b7b;text-transform:uppercase;font-size:11px;letter-spacing:1px;width:44px;padding-right:0;-webkit-box-shadow:none;box-shadow:none;text-shadow:none;z-index:2}
+.field-multilingual .ml-btn:hover{color:#555}
+.field-multilingual.field-multilingual-text .form-control{padding-right:44px}
+.field-multilingual.field-multilingual-text .ml-btn{right:4px;top:50%;margin-top:-22px;height:44px}
+.field-multilingual.field-multilingual-textarea textarea{padding-right:30px}
+.field-multilingual.field-multilingual-textarea .ml-btn{right:25px;top:5px;width:24px;text-align:right;padding-left:4px}
+.field-multilingual.field-multilingual-textarea .ml-dropdown-menu{top:39px;right:1px}
+.field-multilingual.field-multilingual-richeditor .ml-btn{top:43px;right:25px;text-align:right}
+.field-multilingual.field-multilingual-richeditor .ml-dropdown-menu{top:74px;right:12px}
+.field-multilingual.field-multilingual-markdowneditor .ml-btn{top:43px;right:25px;text-align:right}
+.field-multilingual.field-multilingual-markdowneditor .ml-dropdown-menu{top:74px;right:12px}
+.field-multilingual.field-multilingual-repeater .ml-btn{top:-27px;right:11px;text-align:right}
+.field-multilingual.field-multilingual-repeater .ml-dropdown-menu{top:0;right:0}
+.field-multilingual.field-multilingual-repeater.is-empty{padding-top:5px}
+.field-multilingual.field-multilingual-repeater.is-empty .ml-btn{top:-10px;right:5px;text-align:right}
+.field-multilingual.field-multilingual-repeater.is-empty .ml-dropdown-menu{top:15px;right:-7px}
+.field-multilingual.field-multilingual-nestedform .ml-btn{top:-3px;right:11px;text-align:right}
+.field-multilingual.field-multilingual-nestedform .ml-dropdown-menu{top:24px;right:-2px}
+.field-multilingual.field-multilingual-nestedform.is-paneled .ml-btn{top:7px;right:15px}
+.field-multilingual.field-multilingual-nestedform.is-paneled .ml-dropdown-menu{top:34px;right:2px}
+.fancy-layout .field-multilingual-text input.form-control{padding-right:44px}
+.help-block.before-field + .field-multilingual.field-multilingual-textarea .ml-btn{top:-41px}
diff --git a/plugins/rainlab/translate/assets/js/locales.js b/plugins/rainlab/translate/assets/js/locales.js
new file mode 100644
index 0000000..10e652a
--- /dev/null
+++ b/plugins/rainlab/translate/assets/js/locales.js
@@ -0,0 +1,28 @@
+/*
+ * Scripts for the Locales controller.
+ */
++function ($) { "use strict";
+
+ var TranslateLocales = function() {
+
+ this.clickRecord = function(recordId) {
+ var newPopup = $('')
+
+ newPopup.popup({
+ handler: 'onUpdateForm',
+ extraData: {
+ 'record_id': recordId,
+ }
+ })
+ }
+
+ this.createRecord = function() {
+ var newPopup = $('')
+ newPopup.popup({ handler: 'onCreateForm' })
+ }
+
+ }
+
+ $.translateLocales = new TranslateLocales;
+
+}(window.jQuery);
\ No newline at end of file
diff --git a/plugins/rainlab/translate/assets/js/messages.js b/plugins/rainlab/translate/assets/js/messages.js
new file mode 100644
index 0000000..84fd27e
--- /dev/null
+++ b/plugins/rainlab/translate/assets/js/messages.js
@@ -0,0 +1,173 @@
+/*
+ * Scripts for the Messages controller.
+ */
++function ($) { "use strict";
+
+ var TranslateMessages = function() {
+ var self = this
+
+ this.$form = null
+
+ /*
+ * Table toolbar
+ */
+ this.tableToolbar = null
+
+ /*
+ * Input with the "from" locale value
+ */
+ this.fromInput = null
+
+ /*
+ * Template for the "from" header (title)
+ */
+ this.fromHeader = null
+
+ /*
+ * Input with the "to" locale value
+ */
+ this.toInput = null
+
+ /*
+ * Template for the "to" header (title)
+ */
+ this.toHeader = null
+
+ /*
+ * Template for the "found" header (title)
+ */
+ this.foundHeader = null
+
+ /*
+ * The table widget element
+ */
+ this.tableElement = null
+
+ /*
+ * Hide translated strings (show only from the empty data set)
+ */
+ this.hideTranslated = false
+
+ /*
+ * Data sets, complete and untranslated (empty)
+ */
+ this.emptyDataSet = null
+ this.dataSet = null
+
+ $(document).on('change', '#hideTranslated', function(){
+ self.toggleTranslated($(this).is(':checked'))
+ self.refreshTable()
+ });
+
+ $(document).on('keyup', '.control-table input.string-input', function(ev) {
+ self.onApplyValue(ev)
+ });
+
+ this.toggleTranslated = function(isHide) {
+ this.hideTranslated = isHide
+ this.setTitleContents()
+ }
+
+ this.setToolbarContents = function(tableToolbar) {
+ if (tableToolbar) this.tableToolbar = $(tableToolbar)
+ if (!this.tableElement) return
+
+ var $toolbar = $('.toolbar', this.tableElement)
+ if ($toolbar.hasClass('message-buttons-added')) {
+ return
+ }
+
+ if (!this.tableToolbar.length) {
+ return;
+ }
+
+ $toolbar.addClass('message-buttons-added')
+ $toolbar.prepend(Mustache.render(this.tableToolbar.html()))
+ }
+
+ this.setTitleContents = function(fromEl, toEl, foundEl) {
+ if (fromEl) this.fromHeader = $(fromEl)
+ if (toEl) this.toHeader = $(toEl)
+ if (foundEl) this.foundHeader = $(foundEl)
+ if (!this.tableElement) return
+
+ if (!this.toHeader.length) {
+ return;
+ }
+
+ var $headers = $('table.headers th', this.tableElement)
+ $headers.eq(0).html(this.fromHeader.html())
+ $headers.eq(1).html(Mustache.render(this.toHeader.html(), { hideTranslated: this.hideTranslated } ))
+ $headers.eq(2).html(this.foundHeader.html())
+ }
+
+ this.setTableElement = function(el) {
+ this.tableElement = $(el)
+ this.$form = $('#messagesForm')
+ this.fromInput = this.$form.find('input[name=locale_from]')
+ this.toInput = this.$form.find('input[name=locale_to]')
+
+ this.tableElement.one('oc.tableUpdateData', $.proxy(this.updateTableData, this))
+ }
+
+ this.onApplyValue = function(ev) {
+ if (ev.keyCode == 13) {
+ var $table = $(ev.currentTarget).closest('[data-control=table]')
+
+ if (!$table.length) {
+ return
+ }
+
+ var tableObj = $table.data('oc.table')
+ if (tableObj) {
+ tableObj.setCellValue($(ev.currentTarget).closest('td').get(0), ev.currentTarget.value)
+ tableObj.commitEditedRow()
+ }
+ }
+ }
+
+ this.updateTableData = function(event, records) {
+ if (this.hideTranslated && !records.length) {
+ self.toggleTranslated($(this).is(':checked'))
+ self.refreshTable()
+ }
+ }
+
+ this.toggleDropdown = function(el) {
+ setTimeout(function(){ $(el).dropdown('toggle') }, 1)
+ return false
+ }
+
+ this.setLanguage = function(type, code) {
+ if (type == 'to')
+ this.toInput.val(code)
+ else if (type == 'from')
+ this.fromInput.val(code)
+
+ this.refreshGrid()
+ return false
+ }
+
+ this.swapLanguages = function() {
+ var from = this.fromInput.val(),
+ to = this.toInput.val()
+
+ this.toggleTranslated(false)
+ this.fromInput.val(to)
+ this.toInput.val(from)
+ this.refreshGrid()
+ }
+
+ this.refreshGrid = function() {
+ this.$form.request('onRefresh')
+ }
+
+ this.refreshTable = function() {
+ this.tableElement.table('updateDataTable')
+ }
+
+ }
+
+ $.translateMessages = new TranslateMessages;
+
+}(window.jQuery);
diff --git a/plugins/rainlab/translate/assets/js/multilingual.js b/plugins/rainlab/translate/assets/js/multilingual.js
new file mode 100644
index 0000000..2fd6bc3
--- /dev/null
+++ b/plugins/rainlab/translate/assets/js/multilingual.js
@@ -0,0 +1,145 @@
+/*
+ * Multi lingual control plugin
+ *
+ * Data attributes:
+ * - data-control="multilingual" - enables the plugin on an element
+ * - data-default-locale="en" - default locale code
+ * - data-placeholder-field="#placeholderField" - an element that contains the placeholder value
+ *
+ * JavaScript API:
+ * $('a#someElement').multiLingual({ option: 'value' })
+ *
+ * Dependences:
+ * - Nil
+ */
+
++function ($) { "use strict";
+
+ // MULTILINGUAL CLASS DEFINITION
+ // ============================
+
+ var MultiLingual = function(element, options) {
+ var self = this
+ this.options = options
+ this.$el = $(element)
+
+ this.$activeField = null
+ this.$activeButton = $('[data-active-locale]', this.$el)
+ this.$dropdown = $('ul.ml-dropdown-menu', this.$el)
+ this.$placeholder = $(this.options.placeholderField)
+
+ /*
+ * Init locale
+ */
+ this.activeLocale = this.options.defaultLocale
+ this.$activeField = this.getLocaleElement(this.activeLocale)
+ this.$activeButton.text(this.activeLocale)
+
+ this.$dropdown.on('click', '[data-switch-locale]', this.$activeButton, function(event){
+ var currentLocale = event.data.text();
+ var selectedLocale = $(this).data('switch-locale')
+
+ // only call setLocale() if locale has changed
+ if (selectedLocale != currentLocale) {
+ self.setLocale(selectedLocale)
+ }
+
+ /*
+ * If Ctrl/Cmd key is pressed, find other instances and switch
+ */
+ if (event.ctrlKey || event.metaKey) {
+ event.preventDefault();
+ $('[data-switch-locale="'+selectedLocale+'"]').click()
+ }
+ })
+
+ this.$placeholder.on('input', function(){
+ self.$activeField.val(this.value)
+ })
+
+ /*
+ * Handle oc.inputPreset.beforeUpdate event
+ */
+ $('[data-input-preset]', this.$el).on('oc.inputPreset.beforeUpdate', function(event, src) {
+ var sourceLocale = src.siblings('.ml-btn[data-active-locale]').text()
+ var targetLocale = $(this).data('locale-value')
+ var targetActiveLocale = $(this).siblings('.ml-btn[data-active-locale]').text()
+
+ if (sourceLocale && targetLocale && targetActiveLocale) {
+ if (targetActiveLocale !== sourceLocale)
+ self.setLocale(sourceLocale)
+ $(this).data('update', sourceLocale === targetLocale)
+ }
+ })
+ }
+
+ MultiLingual.DEFAULTS = {
+ defaultLocale: 'en',
+ defaultField: null,
+ placeholderField: null
+ }
+
+ MultiLingual.prototype.getLocaleElement = function(locale) {
+ var el = this.$el.find('[data-locale-value="'+locale+'"]')
+ return el.length ? el : null
+ }
+
+ MultiLingual.prototype.getLocaleValue = function(locale) {
+ var value = this.getLocaleElement(locale)
+ return value ? value.val() : null
+ }
+
+ MultiLingual.prototype.setLocaleValue = function(value, locale) {
+ if (locale) {
+ this.getLocaleElement(locale).val(value)
+ }
+ else {
+ this.$activeField.val(value)
+ }
+ }
+
+ MultiLingual.prototype.setLocale = function(locale) {
+ this.activeLocale = locale
+ this.$activeField = this.getLocaleElement(locale)
+ this.$activeButton.text(locale)
+
+ this.$placeholder.val(this.getLocaleValue(locale))
+ this.$el.trigger('setLocale.oc.multilingual', [locale, this.getLocaleValue(locale)])
+ }
+
+ // MULTILINGUAL PLUGIN DEFINITION
+ // ============================
+
+ var old = $.fn.multiLingual
+
+ $.fn.multiLingual = function (option) {
+ var args = Array.prototype.slice.call(arguments, 1), result
+ this.each(function () {
+ var $this = $(this)
+ var data = $this.data('oc.multilingual')
+ var options = $.extend({}, MultiLingual.DEFAULTS, $this.data(), typeof option == 'object' && option)
+ if (!data) $this.data('oc.multilingual', (data = new MultiLingual(this, options)))
+ if (typeof option == 'string') result = data[option].apply(data, args)
+ if (typeof result != 'undefined') return false
+ })
+
+ return result ? result : this
+ }
+
+ $.fn.multiLingual.Constructor = MultiLingual
+
+ // MULTILINGUAL NO CONFLICT
+ // =================
+
+ $.fn.multiLingual.noConflict = function () {
+ $.fn.multiLingual = old
+ return this
+ }
+
+ // MULTILINGUAL DATA-API
+ // ===============
+ $(document).render(function () {
+ $('[data-control="multilingual"]').multiLingual()
+ })
+
+}(window.jQuery);
diff --git a/plugins/rainlab/translate/assets/less/messages.less b/plugins/rainlab/translate/assets/less/messages.less
new file mode 100644
index 0000000..7a76a04
--- /dev/null
+++ b/plugins/rainlab/translate/assets/less/messages.less
@@ -0,0 +1,72 @@
+#messagesContainer {
+ padding-top: 20px;
+}
+
+.translate-messages {
+ position: relative;
+ margin-top: -20px;
+
+ .dropdown-button-placeholder {
+ display:block;
+ width: 1px;
+ height: 20px
+ }
+
+ .dropdown-to, .dropdown-from {
+ position: absolute;
+ top: 57px;
+ }
+
+ .dropdown-to {
+ left: 50%;
+ }
+
+ .dropdown-from {
+ left: 0;
+ }
+
+ .no-other-languages {
+ text-align: center;
+ padding: 5px;
+ }
+
+ .header-language,
+ .header-swap-languages,
+ .header-hide-translated {
+ line-height: 27px;
+ }
+
+ .header-language {
+ float: left;
+ cursor: pointer;
+
+ span.is-default {
+ text-transform: none;
+ }
+ }
+
+ .header-swap-languages {
+ margin-right: 10px;
+ float: right;
+ font-size: 16px;
+ cursor: pointer;
+ }
+
+ .header-hide-translated {
+ float: right;
+ text-align: right;
+
+ label {
+ font-weight: normal;
+ margin-bottom: 0;
+ margin-right: 10px;
+ font-size: 12px;
+ white-space: normal;
+
+ &:before {
+ top: 5px;
+ left: 0;
+ }
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/assets/less/multilingual.less b/plugins/rainlab/translate/assets/less/multilingual.less
new file mode 100644
index 0000000..8024ea5
--- /dev/null
+++ b/plugins/rainlab/translate/assets/less/multilingual.less
@@ -0,0 +1,148 @@
+@import "../../../../../modules/backend/assets/less/core/boot.less";
+
+@multilingual-btn-color: #555555;
+
+.field-multilingual {
+ position: relative;
+
+ .ml-btn {
+ background: transparent;
+ position: absolute;
+ color: lighten(@multilingual-btn-color, 15%);
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 1px;
+ width: 44px;
+ padding-right: 0;
+ .box-shadow(none);
+ text-shadow: none;
+ z-index: 2;
+
+ &:hover {
+ color: @multilingual-btn-color;
+ }
+ }
+
+ &.field-multilingual-text {
+ .form-control {
+ padding-right: 44px;
+ }
+ .ml-btn {
+ right: 4px;
+ top: 50%;
+ margin-top: -22px;
+ height: 44px;
+ }
+ }
+
+ &.field-multilingual-textarea {
+ textarea {
+ // increase padding on the right so that the textarea content does not overlap with the language button
+ padding-right: 30px;
+ }
+
+ .ml-btn {
+ right: 25px;
+ top: 5px;
+ width: 24px;
+ text-align: right;
+ padding-left: 4px;
+ }
+
+ .ml-dropdown-menu {
+ top: 39px;
+ right: 1px;
+ }
+ }
+
+ &.field-multilingual-richeditor {
+ .ml-btn {
+ top: 43px;
+ right: 25px;
+ text-align: right;
+ }
+
+ .ml-dropdown-menu {
+ top: 74px;
+ right: 12px;
+ }
+ }
+
+ &.field-multilingual-markdowneditor {
+ .ml-btn {
+ top: 43px;
+ right: 25px;
+ text-align: right;
+ }
+
+ .ml-dropdown-menu {
+ top: 74px;
+ right: 12px;
+ }
+ }
+
+ &.field-multilingual-repeater {
+ .ml-btn {
+ top: -27px;
+ right: 11px;
+ text-align: right;
+ }
+
+ .ml-dropdown-menu {
+ top: 0;
+ right: 0;
+ }
+
+ &.is-empty {
+ padding-top: 5px;
+
+ .ml-btn {
+ top: -10px;
+ right: 5px;
+ text-align: right;
+ }
+
+ .ml-dropdown-menu {
+ top: 15px;
+ right: -7px;
+ }
+ }
+ }
+
+ &.field-multilingual-nestedform {
+ .ml-btn {
+ top: -3px;
+ right: 11px;
+ text-align: right;
+ }
+
+ .ml-dropdown-menu {
+ top: 24px;
+ right: -2px;
+ }
+
+ &.is-paneled {
+ .ml-btn {
+ top: 7px;
+ right: 15px;
+ }
+
+ .ml-dropdown-menu {
+ top: 34px;
+ right: 2px;
+ }
+ }
+ }
+}
+
+.fancy-layout {
+ .field-multilingual-text input.form-control {
+ padding-right: 44px;
+ }
+}
+
+.help-block.before-field + .field-multilingual.field-multilingual-textarea {
+ .ml-btn {
+ top: -41px;
+ }
+}
diff --git a/plugins/rainlab/translate/behaviors/TranslatableCmsObject.php b/plugins/rainlab/translate/behaviors/TranslatableCmsObject.php
new file mode 100644
index 0000000..f327f2a
--- /dev/null
+++ b/plugins/rainlab/translate/behaviors/TranslatableCmsObject.php
@@ -0,0 +1,230 @@
+model->bindEvent('cmsObject.fillViewBagArray', function() {
+ $this->mergeViewBagAttributes();
+ });
+
+ $this->model->bindEvent('cmsObject.getTwigCacheKey', function($key) {
+ return $this->overrideTwigCacheKey($key);
+ });
+
+ // delete all translation files associated with the default language static page
+ $this->model->bindEvent('model.afterDelete', function() use ($model) {
+ foreach (Locale::listEnabled() as $locale => $label) {
+ if ($locale == $this->translatableDefault) {
+ continue;
+ }
+ if ($obj = $this->getCmsObjectForLocale($locale)) {
+ $obj->delete();
+ }
+ }
+ });
+ }
+
+ /**
+ * Merge the viewBag array for the base and translated objects.
+ * @return void
+ */
+ protected function mergeViewBagAttributes()
+ {
+ $locale = $this->translatableContext;
+
+ if (!array_key_exists($locale, $this->translatableAttributes)) {
+ $this->loadTranslatableData($locale);
+ }
+
+ if (isset($this->translatableViewBag[$locale])) {
+ $this->model->viewBag = array_merge(
+ $this->model->viewBag,
+ $this->translatableViewBag[$locale]
+ );
+ }
+ }
+
+ /**
+ * Translated CMS objects need their own unique cache key in twig.
+ * @return string|null
+ */
+ protected function overrideTwigCacheKey($key)
+ {
+ if (!$locale = $this->translatableContext) {
+ return null;
+ }
+
+ return $key . '-' . $locale;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function syncTranslatableAttributes()
+ {
+ parent::syncTranslatableAttributes();
+
+ if ($this->model->isDirty('fileName')) {
+ $this->syncTranslatableFileNames();
+ }
+ }
+
+ /**
+ * If the parent model file name is changed, this should
+ * be reflected in the translated models also.
+ */
+ protected function syncTranslatableFileNames()
+ {
+ $knownLocales = array_keys($this->translatableAttributes);
+ foreach ($knownLocales as $locale) {
+ if ($locale == $this->translatableDefault) {
+ continue;
+ }
+
+ if ($obj = $this->getCmsObjectForLocale($locale)) {
+ $obj->fileName = $this->model->fileName;
+ $obj->forceSave();
+ }
+ }
+ }
+
+ /**
+ * Saves the translation data in the join table.
+ * @param string $locale
+ * @return void
+ */
+ protected function storeTranslatableData($locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ /*
+ * Model doesn't exist yet, defer this logic in memory
+ */
+ if (!$this->model->exists) {
+ $this->model->bindEventOnce('model.afterCreate', function() use ($locale) {
+ $this->storeTranslatableData($locale);
+ });
+
+ return;
+ }
+
+ $data = $this->translatableAttributes[$locale];
+
+ if (!$obj = $this->getCmsObjectForLocale($locale)) {
+ $model = $this->createModel();
+ $obj = $model::forLocale($locale, $this->model);
+ $obj->fileName = $this->model->fileName;
+ }
+
+ if (!$this->isEmptyDataSet($data)) {
+ $obj->fill($data);
+ $obj->forceSave();
+ }
+ }
+
+ /**
+ * Returns true if all attributes are empty (false when converted to booleans).
+ * @param array $data
+ * @return bool
+ */
+ protected function isEmptyDataSet($data)
+ {
+ return !array_get($data, 'markup') &&
+ !count(array_filter(array_get($data, 'viewBag', []))) &&
+ !count(array_filter(array_get($data, 'placeholders', [])));
+ }
+
+ /**
+ * Loads the translation data from the join table.
+ * @param string $locale
+ * @return array
+ */
+ protected function loadTranslatableData($locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ if (!$this->model->exists) {
+ return $this->translatableAttributes[$locale] = [];
+ }
+
+ $obj = $this->getCmsObjectForLocale($locale);
+
+ $result = $obj ? $obj->getAttributes() : [];
+
+ $this->translatableViewBag[$locale] = $obj ? $obj->viewBag : [];
+
+ return $this->translatableOriginals[$locale] = $this->translatableAttributes[$locale] = $result;
+ }
+
+ protected function getCmsObjectForLocale($locale)
+ {
+ if ($locale == $this->translatableDefault) {
+ return $this->model;
+ }
+
+ $model = $this->createModel();
+ return $model::findLocale($locale, $this->model);
+ }
+
+ /**
+ * Internal method, prepare the form model object
+ * @return Model
+ */
+ protected function createModel()
+ {
+ $class = $this->getTranslatableModelClass();
+ $model = new $class;
+ return $model;
+ }
+
+ /**
+ * Returns a collection of fields that will be hashed.
+ * @return array
+ */
+ public function getTranslatableModelClass()
+ {
+ if (property_exists($this->model, 'translatableModel')) {
+ return $this->model->translatableModel;
+ }
+
+ return 'RainLab\Translate\Classes\MLCmsObject';
+ }
+}
diff --git a/plugins/rainlab/translate/behaviors/TranslatableModel.php b/plugins/rainlab/translate/behaviors/TranslatableModel.php
new file mode 100644
index 0000000..21c759f
--- /dev/null
+++ b/plugins/rainlab/translate/behaviors/TranslatableModel.php
@@ -0,0 +1,357 @@
+morphMany['translations'] = [
+ 'RainLab\Translate\Models\Attribute',
+ 'name' => 'model'
+ ];
+
+ // October v2.0
+ if (class_exists('System')) {
+ $this->extendFileModels('attachOne');
+ $this->extendFileModels('attachMany');
+ }
+
+ // Clean up indexes when this model is deleted
+ $model->bindEvent('model.afterDelete', function() use ($model) {
+ Db::table('rainlab_translate_attributes')
+ ->where('model_id', $model->getKey())
+ ->where('model_type', get_class($model))
+ ->delete();
+
+ Db::table('rainlab_translate_indexes')
+ ->where('model_id', $model->getKey())
+ ->where('model_type', get_class($model))
+ ->delete();
+ });
+ }
+
+ /**
+ * extendFileModels will swap the standard File model with MLFile instead
+ */
+ protected function extendFileModels(string $relationGroup)
+ {
+ foreach ($this->model->$relationGroup as $relationName => $relationObj) {
+ $relationClass = is_array($relationObj) ? $relationObj[0] : $relationObj;
+ if ($relationClass === \System\Models\File::class) {
+ if (is_array($relationObj)) {
+ $this->model->$relationGroup[$relationName][0] = \RainLab\Translate\Models\MLFile::class;
+ }
+ else {
+ $this->model->$relationGroup[$relationName] = \RainLab\Translate\Models\MLFile::class;
+ }
+ }
+ }
+ }
+
+ /**
+ * scopeTransWhere applies a translatable index to a basic query. This scope will join the
+ * index table and can be executed neither more than once, nor with scopeTransOrder.
+ * @param Builder $query
+ * @param string $index
+ * @param string $value
+ * @param string $locale
+ * @return Builder
+ */
+ public function scopeTransWhere($query, $index, $value, $locale = null, $operator = '=')
+ {
+ return $this->transWhereInternal($query, $index, $value, [
+ 'locale' => $locale,
+ 'operator' => $operator
+ ]);
+ }
+
+ /**
+ * scopeTransWhereNoFallback is identical to scopeTransWhere except it will not
+ * use a fallback query when there are no indexes found.
+ * @see scopeTransWhere
+ */
+ public function scopeTransWhereNoFallback($query, $index, $value, $locale = null, $operator = '=')
+ {
+ // Ignore translatable indexes in default locale context
+ if(($locale ?: $this->translatableContext) === $this->translatableDefault) {
+ return $query->where($index, $operator, $value);
+ }
+
+ return $this->transWhereInternal($query, $index, $value, [
+ 'locale' => $locale,
+ 'operator' => $operator,
+ 'noFallback' => true
+ ]);
+ }
+
+ /**
+ * transWhereInternal
+ * @link https://github.com/rainlab/translate-plugin/pull/623
+ */
+ protected function transWhereInternal($query, $index, $value, $options = [])
+ {
+ extract(array_merge([
+ 'locale' => null,
+ 'operator' => '=',
+ 'noFallback' => false
+ ], $options));
+
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ // Separate query into two separate queries for improved performance
+ $translateIndexes = Db::table('rainlab_translate_indexes')
+ ->where('rainlab_translate_indexes.model_type', '=', $this->getClass())
+ ->where('rainlab_translate_indexes.locale', '=', $locale)
+ ->where('rainlab_translate_indexes.item', $index)
+ ->where('rainlab_translate_indexes.value', $operator, $value)
+ ->pluck('model_id')
+ ;
+
+ if ($translateIndexes->count() || $noFallback) {
+ $query->whereIn($this->model->getQualifiedKeyName(), $translateIndexes);
+ }
+ else {
+ $query->where($index, $operator, $value);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Applies a sort operation with a translatable index to a basic query. This scope will join the index table.
+ * @param Builder $query
+ * @param string $index
+ * @param string $direction
+ * @param string $locale
+ * @return Builder
+ */
+ public function scopeTransOrderBy($query, $index, $direction = 'asc', $locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+ $indexTableAlias = 'rainlab_translate_indexes_' . $index . '_' . $locale;
+
+ $query->select(
+ $this->model->getTable().'.*',
+ Db::raw('COALESCE(' . $indexTableAlias . '.value, '. $this->model->getTable() .'.'.$index.') AS translate_sorting_key')
+ );
+
+ $query->orderBy('translate_sorting_key', $direction);
+
+ $this->joinTranslateIndexesTable($query, $locale, $index, $indexTableAlias);
+
+ return $query;
+ }
+
+ /**
+ * Joins the translatable indexes table to a query.
+ * @param Builder $query
+ * @param string $locale
+ * @param string $indexTableAlias
+ * @return Builder
+ */
+ protected function joinTranslateIndexesTable($query, $locale, $index, $indexTableAlias)
+ {
+ $joinTableWithAlias = 'rainlab_translate_indexes as ' . $indexTableAlias;
+ // check if table with same name and alias is already joined
+ if (collect($query->getQuery()->joins)->contains('table', $joinTableWithAlias)) {
+ return $query;
+ }
+
+ $query->leftJoin($joinTableWithAlias, function($join) use ($locale, $index, $indexTableAlias) {
+ $join
+ ->on(Db::raw(DbDongle::cast($this->model->getQualifiedKeyName(), 'TEXT')), '=', $indexTableAlias . '.model_id')
+ ->where($indexTableAlias . '.model_type', '=', $this->getClass())
+ ->where($indexTableAlias . '.item', '=', $index)
+ ->where($indexTableAlias . '.locale', '=', $locale);
+ });
+
+ return $query;
+ }
+
+ /**
+ * Saves the translation data in the join table.
+ * @param string $locale
+ * @return void
+ */
+ protected function storeTranslatableData($locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ /*
+ * Model doesn't exist yet, defer this logic in memory
+ */
+ if (!$this->model->exists) {
+ $this->model->bindEventOnce('model.afterCreate', function() use ($locale) {
+ $this->storeTranslatableData($locale);
+ });
+
+ return;
+ }
+
+ /**
+ * @event model.translate.resolveComputedFields
+ * Resolve computed fields before saving
+ *
+ * Example usage:
+ *
+ * Override Model's __construct method
+ *
+ * public function __construct(array $attributes = [])
+ * {
+ * parent::__construct($attributes);
+ *
+ * $this->bindEvent('model.translate.resolveComputedFields', function ($locale) {
+ * return [
+ * 'content_html' =>
+ * self::formatHtml($this->asExtension('TranslatableModel')
+ * ->getAttributeTranslated('content', $locale))
+ * ];
+ * });
+ * }
+ *
+ */
+ $computedFields = $this->model->fireEvent('model.translate.resolveComputedFields', [$locale], true);
+ if (is_array($computedFields)) {
+ $this->translatableAttributes[$locale] = array_merge($this->translatableAttributes[$locale], $computedFields);
+ }
+
+ $this->storeTranslatableBasicData($locale);
+ $this->storeTranslatableIndexData($locale);
+ }
+
+ /**
+ * Saves the basic translation data in the join table.
+ * @param string $locale
+ * @return void
+ */
+ protected function storeTranslatableBasicData($locale = null)
+ {
+ $data = json_encode($this->translatableAttributes[$locale], JSON_UNESCAPED_UNICODE);
+
+ $obj = Db::table('rainlab_translate_attributes')
+ ->where('locale', $locale)
+ ->where('model_id', $this->model->getKey())
+ ->where('model_type', $this->getClass());
+
+ if ($obj->count() > 0) {
+ $obj->update(['attribute_data' => $data]);
+ }
+ else {
+ Db::table('rainlab_translate_attributes')->insert([
+ 'locale' => $locale,
+ 'model_id' => $this->model->getKey(),
+ 'model_type' => $this->getClass(),
+ 'attribute_data' => $data
+ ]);
+ }
+ }
+
+ /**
+ * Saves the indexed translation data in the join table.
+ * @param string $locale
+ * @return void
+ */
+ protected function storeTranslatableIndexData($locale = null)
+ {
+ $optionedAttributes = $this->getTranslatableAttributesWithOptions();
+ if (!count($optionedAttributes)) {
+ return;
+ }
+
+ $data = $this->translatableAttributes[$locale];
+
+ foreach ($optionedAttributes as $attribute => $options) {
+ if (!array_get($options, 'index', false)) {
+ continue;
+ }
+
+ $value = array_get($data, $attribute);
+
+ $obj = Db::table('rainlab_translate_indexes')
+ ->where('locale', $locale)
+ ->where('model_id', $this->model->getKey())
+ ->where('model_type', $this->getClass())
+ ->where('item', $attribute);
+
+ $recordExists = $obj->count() > 0;
+
+ if (!strlen($value)) {
+ if ($recordExists) {
+ $obj->delete();
+ }
+ continue;
+ }
+
+ if ($recordExists) {
+ $obj->update(['value' => $value]);
+ }
+ else {
+ Db::table('rainlab_translate_indexes')->insert([
+ 'locale' => $locale,
+ 'model_id' => $this->model->getKey(),
+ 'model_type' => $this->getClass(),
+ 'item' => $attribute,
+ 'value' => $value
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Loads the translation data from the join table.
+ * @param string $locale
+ * @return array
+ */
+ protected function loadTranslatableData($locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ if (!$this->model->exists) {
+ return $this->translatableAttributes[$locale] = [];
+ }
+
+ $obj = $this->model->translations->first(function ($value, $key) use ($locale) {
+ return $value->attributes['locale'] === $locale;
+ });
+
+ $result = $obj ? json_decode($obj->attribute_data, true) : [];
+
+ return $this->translatableOriginals[$locale] = $this->translatableAttributes[$locale] = $result;
+ }
+
+ /**
+ * Returns the class name of the model. Takes any
+ * custom morphMap aliases into account.
+ *
+ * @return string
+ */
+ protected function getClass()
+ {
+ return $this->model->getMorphClass();
+ }
+}
diff --git a/plugins/rainlab/translate/behaviors/TranslatablePage.php b/plugins/rainlab/translate/behaviors/TranslatablePage.php
new file mode 100644
index 0000000..4797e29
--- /dev/null
+++ b/plugins/rainlab/translate/behaviors/TranslatablePage.php
@@ -0,0 +1,140 @@
+model->bindEvent('model.afterFetch', function() {
+ $this->translatableOriginals = $this->getModelAttributes();
+
+ if (!App::runningInBackend()) {
+ $this->rewriteTranslatablePageAttributes();
+ }
+ });
+ }
+
+ public function isTranslatable($key)
+ {
+ if ($key === 'translatable' || $this->translatableDefault == $this->translatableContext) {
+ return false;
+ }
+
+ return in_array($key, $this->model->translatable);
+ }
+
+ public function getTranslatableAttributes()
+ {
+ $attributes = [];
+
+ foreach ($this->model->translatable as $attr) {
+ $attributes[] = 'settings['.$attr.']';
+ }
+
+ return $attributes;
+ }
+
+ public function getModelAttributes()
+ {
+ $attributes = [];
+
+ foreach ($this->model->translatable as $attr) {
+ $attributes[$attr] = $this->model[$attr];
+ }
+
+ return $attributes;
+ }
+
+ public function initTranslatableContext()
+ {
+ parent::initTranslatableContext();
+ $this->translatableOriginals = $this->getModelAttributes();
+ }
+
+ public function rewriteTranslatablePageAttributes($locale = null)
+ {
+ $locale = $locale ?: $this->translatableContext;
+
+ foreach ($this->model->translatable as $attr) {
+ $localeAttr = $this->translatableOriginals[$attr];
+
+ if ($locale != $this->translatableDefault) {
+ $translated = $this->getAttributeTranslated($attr, $locale);
+ $localeAttr = ($translated ?: $this->translatableUseFallback) ? $localeAttr : null;
+ }
+
+ $this->model[$attr] = $localeAttr;
+ }
+ }
+
+ public function getAttributeTranslated($key, $locale = null)
+ {
+ $locale = $locale ?: $this->translatableContext;
+
+ if (strpbrk($key, '[]') !== false) {
+ // Retrieve attr name within brackets (i.e. settings[title] yields title)
+ $key = preg_split("/[\[\]]/", $key)[1];
+ }
+
+ $default = ($locale == $this->translatableDefault || $this->translatableUseFallback)
+ ? array_get($this->translatableOriginals, $key)
+ : '';
+
+ $localeAttr = sprintf('viewBag.locale%s.%s', ucfirst($key), $locale);
+ return array_get($this->model->attributes, $localeAttr, $default);
+ }
+
+ public function setAttributeTranslated($key, $value, $locale = null)
+ {
+ $locale = $locale ?: $this->translatableContext;
+
+ if ($locale == $this->translatableDefault) {
+ return;
+ }
+
+ if (strpbrk($key, '[]') !== false) {
+ // Retrieve attr name within brackets (i.e. settings[title] yields title)
+ $key = preg_split("/[\[\]]/", $key)[1];
+ }
+
+ if ($value == array_get($this->translatableOriginals, $key)) {
+ return;
+ }
+
+ $this->saveTranslation($key, $value, $locale);
+ $this->model->bindEventOnce('model.beforeSave', function() use ($key, $value, $locale) {
+ $this->saveTranslation($key, $value, $locale);
+ });
+ }
+
+ public function saveTranslation($key, $value, $locale)
+ {
+ $localeAttr = sprintf('viewBag.locale%s.%s', ucfirst($key), $locale);
+ if (!$value) {
+ array_forget($this->model->attributes, $localeAttr);
+ }
+ else {
+ array_set($this->model->attributes, $localeAttr, $value);
+ }
+ }
+
+ // Not needed but parent abstract model requires those
+ protected function storeTranslatableData($locale = null) {}
+ protected function loadTranslatableData($locale = null) {}
+}
diff --git a/plugins/rainlab/translate/behaviors/TranslatablePageUrl.php b/plugins/rainlab/translate/behaviors/TranslatablePageUrl.php
new file mode 100644
index 0000000..bbc7147
--- /dev/null
+++ b/plugins/rainlab/translate/behaviors/TranslatablePageUrl.php
@@ -0,0 +1,173 @@
+model = $model;
+
+ $this->initTranslatableContext();
+
+ $this->model->bindEvent('model.afterFetch', function() {
+ $this->translatableDefaultUrl = $this->getModelUrl();
+
+ if (!App::runningInBackend()) {
+ $this->rewriteTranslatablePageUrl();
+ }
+ });
+ }
+
+ protected function setModelUrl($value)
+ {
+ if ($this->model instanceof \RainLab\Pages\Classes\Page) {
+ array_set($this->model->attributes, 'viewBag.url', $value);
+ }
+ else {
+ $this->model->url = $value;
+ }
+ }
+
+ protected function getModelUrl()
+ {
+ if ($this->model instanceof \RainLab\Pages\Classes\Page) {
+ return array_get($this->model->attributes, 'viewBag.url');
+ }
+ else {
+ return $this->model->url;
+ }
+ }
+
+ /**
+ * Initializes this class, sets the default language code to use.
+ * @return void
+ */
+ public function initTranslatableContext()
+ {
+ $translate = Translator::instance();
+ $this->translatableContext = $translate->getLocale();
+ $this->translatableDefault = $translate->getDefaultLocale();
+ }
+
+ /**
+ * Checks if a translated URL exists and rewrites it, this method
+ * should only be called from the context of front-end.
+ * @return void
+ */
+ public function rewriteTranslatablePageUrl($locale = null)
+ {
+ $locale = $locale ?: $this->translatableContext;
+ $localeUrl = $this->translatableDefaultUrl;
+
+ if ($locale != $this->translatableDefault) {
+ $localeUrl = $this->getSettingsUrlAttributeTranslated($locale) ?: $localeUrl;
+ }
+
+ $this->setModelUrl($localeUrl);
+ }
+
+ /**
+ * Determines if a locale has a translated URL.
+ * @return bool
+ */
+ public function hasTranslatablePageUrl($locale = null)
+ {
+ $locale = $locale ?: $this->translatableContext;
+
+ return strlen($this->getSettingsUrlAttributeTranslated($locale)) > 0;
+ }
+
+ /**
+ * Mutator detected by MLControl
+ * @return string
+ */
+ public function getSettingsUrlAttributeTranslated($locale)
+ {
+ $defaults = ($locale == $this->translatableDefault) ? $this->translatableDefaultUrl : null;
+
+ return array_get($this->model->attributes, 'viewBag.localeUrl.'.$locale, $defaults);
+ }
+
+ /**
+ * Mutator detected by MLControl
+ * @return void
+ */
+ public function setSettingsUrlAttributeTranslated($value, $locale)
+ {
+ if ($locale == $this->translatableDefault) {
+ return;
+ }
+
+ if ($value == $this->translatableDefaultUrl) {
+ return;
+ }
+
+ /*
+ * The CMS controller will purge attributes just before saving, this
+ * will ensure the attributes are injected after this logic.
+ */
+ $this->model->bindEventOnce('model.beforeSave', function() use ($value, $locale) {
+ if (!$value) {
+ array_forget($this->model->attributes, 'viewBag.localeUrl.'.$locale);
+ }
+ else {
+ array_set($this->model->attributes, 'viewBag.localeUrl.'.$locale, $value);
+ }
+ });
+ }
+
+ /**
+ * Mutator detected by MLControl, proxy for Static Pages plugin.
+ * @return string
+ */
+ public function getViewBagUrlAttributeTranslated($locale)
+ {
+ return $this->getSettingsUrlAttributeTranslated($locale);
+ }
+
+ /**
+ * Mutator detected by MLControl, proxy for Static Pages plugin.
+ * @return void
+ */
+ public function setViewBagUrlAttributeTranslated($value, $locale)
+ {
+ $this->setSettingsUrlAttributeTranslated($value, $locale);
+ }
+}
diff --git a/plugins/rainlab/translate/classes/EventRegistry.php b/plugins/rainlab/translate/classes/EventRegistry.php
new file mode 100644
index 0000000..262bf91
--- /dev/null
+++ b/plugins/rainlab/translate/classes/EventRegistry.php
@@ -0,0 +1,408 @@
+code ?? null;
+
+ $properties = [];
+ foreach ($locales as $locale => $label) {
+ if ($locale == $defaultLocale) {
+ continue;
+ }
+
+ $properties[] = [
+ 'property' => 'localeUrl.'.$locale,
+ 'title' => 'cms::lang.editor.url',
+ 'tab' => $label,
+ 'type' => 'string',
+ ];
+
+ $properties[] = [
+ 'property' => 'localeTitle.'.$locale,
+ 'title' => 'cms::lang.editor.title',
+ 'tab' => $label,
+ 'type' => 'string',
+ ];
+
+ $properties[] = [
+ 'property' => 'localeDescription.'.$locale,
+ 'title' => 'cms::lang.editor.description',
+ 'tab' => $label,
+ 'type' => 'text',
+ ];
+
+ $properties[] = [
+ 'property' => 'localeMeta_title.'.$locale,
+ 'title' => 'cms::lang.editor.meta_title',
+ 'tab' => $label,
+ 'type' => 'string',
+ ];
+
+ $properties[] = [
+ 'property' => 'localeMeta_description.'.$locale,
+ 'title' => 'cms::lang.editor.meta_description',
+ 'tab' => $label,
+ 'type' => 'text',
+ ];
+ }
+
+ $dataHolder->buttons[] = [
+ 'button' => 'rainlab.translate::lang.plugin.name',
+ 'icon' => 'octo-icon-globe',
+ 'popupTitle' => 'Translate Page Properties',
+ 'properties' => $properties
+ ];
+ }
+
+ //
+ // Utility
+ //
+
+ /**
+ * registerFormFieldReplacements
+ */
+ public function registerFormFieldReplacements($widget)
+ {
+ // Replace with ML Controls for translatable attributes
+ $this->registerModelTranslation($widget);
+
+ // Handle URL translations
+ $this->registerPageUrlTranslation($widget);
+
+ // Handle RainLab.Pages MenuItem translations
+ if (PluginManager::instance()->exists('RainLab.Pages')) {
+ $this->registerMenuItemTranslation($widget);
+ }
+ }
+
+ /**
+ * registerMenuItemTranslation for RainLab.Pages MenuItem data
+ * @param Backend\Widgets\Form $widget
+ */
+ public function registerMenuItemTranslation($widget)
+ {
+ if ($widget->model instanceof \RainLab\Pages\Classes\MenuItem) {
+ $defaultLocale = LocaleModel::getDefault();
+ $availableLocales = LocaleModel::listAvailable();
+ $fieldsToTranslate = ['title', 'url'];
+
+ // Replace specified fields with multilingual versions
+ foreach ($fieldsToTranslate as $fieldName) {
+ $widget->fields[$fieldName]['type'] = 'mltext';
+
+ foreach ($availableLocales as $code => $locale) {
+ if (!$defaultLocale || $defaultLocale->code === $code) {
+ continue;
+ }
+
+ // Add data locker fields for the different locales under the `viewBag[locale]` property
+ $widget->fields["viewBag[locale][$code][$fieldName]"] = [
+ 'cssClass' => 'hidden',
+ 'attributes' => [
+ 'data-locale' => $code,
+ 'data-field-name' => $fieldName,
+ ],
+ ];
+ }
+ }
+ }
+ }
+
+ //
+ // Translate URLs
+ //
+
+ /**
+ * registerPageUrlTranslation
+ */
+ public function registerPageUrlTranslation($widget)
+ {
+ if (!$model = $widget->model) {
+ return;
+ }
+
+ if (
+ $model instanceof Page &&
+ isset($widget->fields['settings[url]'])
+ ) {
+ $widget->fields['settings[url]']['type'] = 'mltext';
+ }
+ elseif (
+ $model instanceof \RainLab\Pages\Classes\Page &&
+ isset($widget->fields['viewBag[url]'])
+ ) {
+ $widget->fields['viewBag[url]']['type'] = 'mltext';
+ }
+ }
+
+ //
+ // Translatable behavior
+ //
+
+ /**
+ * registerModelTranslation automatically replaces form fields for multi-lingual equivalents
+ */
+ public function registerModelTranslation($widget)
+ {
+ if ($widget->isNested) {
+ return;
+ }
+
+ if (!$model = $widget->model) {
+ return;
+ }
+
+ if (!method_exists($model, 'isClassExtendedWith')) {
+ return;
+ }
+
+ if (
+ !$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatableModel::class) &&
+ !$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatablePage::class) &&
+ !$model->isClassExtendedWith(\RainLab\Translate\Behaviors\TranslatableCmsObject::class)
+ ) {
+ return;
+ }
+
+ if (!$model->hasTranslatableAttributes()) {
+ return;
+ }
+
+ if (!empty($widget->fields)) {
+ $widget->fields = $this->processFormMLFields($widget->fields, $model);
+ }
+
+ if (!empty($widget->tabs['fields'])) {
+ $widget->tabs['fields'] = $this->processFormMLFields($widget->tabs['fields'], $model);
+ }
+
+ if (!empty($widget->secondaryTabs['fields'])) {
+ $widget->secondaryTabs['fields'] = $this->processFormMLFields($widget->secondaryTabs['fields'], $model);
+ }
+ }
+
+ /**
+ * processFormMLFields function to replace standard fields with multi-lingual equivalents
+ * @param array $fields
+ * @param Model $model
+ * @return array
+ */
+ protected function processFormMLFields($fields, $model)
+ {
+ $typesMap = [
+ 'text' => 'mltext',
+ 'textarea' => 'mltextarea',
+ 'richeditor' => 'mlricheditor',
+ 'markdown' => 'mlmarkdowneditor',
+ 'repeater' => 'mlrepeater',
+ 'nestedform' => 'mlnestedform',
+ 'mediafinder' => 'mlmediafinder',
+ ];
+
+ $translatable = array_flip($model->getTranslatableAttributes());
+
+ /*
+ * Special: A custom field "markup_html" is used for Content templates.
+ */
+ if ($model instanceof Content && array_key_exists('markup', $translatable)) {
+ $translatable['markup_html'] = true;
+ }
+
+ foreach ($fields as $name => $config) {
+ if (!array_key_exists($name, $translatable)) {
+ continue;
+ }
+
+ $type = array_get($config, 'type', 'text');
+
+ if (array_key_exists($type, $typesMap)) {
+ $fields[$name]['type'] = $typesMap[$type];
+ }
+ }
+
+ return $fields;
+ }
+
+ //
+ // Theme
+ //
+
+ /**
+ * importMessagesFromTheme
+ */
+ public function importMessagesFromTheme($themeCode)
+ {
+ try {
+ (new ThemeScanner)->scanThemeConfigForMessages($themeCode);
+ }
+ catch (Exception $ex) {}
+ }
+
+ //
+ // CMS objects
+ //
+
+ /**
+ * setMessageContext for translation caching.
+ */
+ public function setMessageContext($page)
+ {
+ if (!$page) {
+ return;
+ }
+
+ $translator = Translator::instance();
+
+ Message::setContext($translator->getLocale(), $page->url);
+ }
+
+ /**
+ * findTranslatedContentFile adds language suffixes to content files.
+ * @return string|null
+ */
+ public function findTranslatedContentFile($controller, $fileName)
+ {
+ if (!strlen(File::extension($fileName))) {
+ $fileName .= '.htm';
+ }
+
+ /*
+ * Splice the active locale in to the filename
+ * - content.htm -> content.en.htm
+ */
+ $locale = Translator::instance()->getLocale();
+ $fileName = substr_replace($fileName, '.'.$locale, strrpos($fileName, '.'), 0);
+ if (($content = Content::loadCached($controller->getTheme(), $fileName)) !== null) {
+ return $content;
+ }
+ }
+
+ //
+ // Static pages
+ //
+
+ /**
+ * pruneTranslatedContentTemplates removes localized content files from templates collection
+ * @param \October\Rain\Database\Collection $templates
+ * @return \October\Rain\Database\Collection
+ */
+ public function pruneTranslatedContentTemplates($templates)
+ {
+ $locales = LocaleModel::listAvailable();
+
+ $extensions = array_map(function($ext) {
+ return '.'.$ext;
+ }, array_keys($locales));
+
+ return $templates->filter(function($template) use ($extensions) {
+ return !Str::endsWith($template->getBaseFileName(), $extensions);
+ });
+ }
+
+ /**
+ * findLocalizedMailViewContent adds language suffixes to mail view files.
+ * @param \October\Rain\Mail\Mailer $mailer
+ * @param \Illuminate\Mail\Message $message
+ * @param string $view
+ * @param array $data
+ * @param string $raw
+ * @param string $plain
+ * @return bool|void Will return false if the translation process successfully replaced the original message with a translated version to prevent the original version from being processed.
+ */
+ public function findLocalizedMailViewContent($mailer, $message, $view, $data, $raw, $plain)
+ {
+ // Raw content cannot be localized at this level
+ if (!empty($raw)) {
+ return;
+ }
+
+ // Get the locale to use for this template
+ $locale = !empty($data['_current_locale']) ? $data['_current_locale'] : App::getLocale();
+
+ $factory = $mailer->getViewFactory();
+
+ if (!empty($view)) {
+ $view = $this->getLocalizedView($factory, $view, $locale);
+ }
+
+ if (!empty($plain)) {
+ $plain = $this->getLocalizedView($factory, $plain, $locale);
+ }
+
+ $code = $view ?: $plain;
+ if (empty($code)) {
+ return null;
+ }
+
+ $plainOnly = empty($view);
+
+ if (MailManager::instance()->addContentToMailer($message, $code, $data, $plainOnly)) {
+ // the caller who fired the event is expecting a FALSE response to halt the event
+ return false;
+ }
+ }
+
+ /**
+ * getLocalizedView searches mail view files based on locale
+ * @param \October\Rain\Mail\Mailer $mailer
+ * @param \Illuminate\Mail\Message $message
+ * @param string $code
+ * @param string $locale
+ * @return string|null
+ */
+ public function getLocalizedView($factory, $code, $locale)
+ {
+ $locale = strtolower($locale);
+
+ $searchPaths[] = $locale;
+
+ if (str_contains($locale, '-')) {
+ list($lang) = explode('-', $locale);
+ $searchPaths[] = $lang;
+ }
+
+ foreach ($searchPaths as $path) {
+ $localizedView = sprintf('%s-%s', $code, $path);
+
+ if ($factory->exists($localizedView)) {
+ return $localizedView;
+ }
+ }
+ return null;
+ }
+}
diff --git a/plugins/rainlab/translate/classes/LocaleMiddleware.php b/plugins/rainlab/translate/classes/LocaleMiddleware.php
new file mode 100644
index 0000000..20d4f03
--- /dev/null
+++ b/plugins/rainlab/translate/classes/LocaleMiddleware.php
@@ -0,0 +1,31 @@
+isConfigured();
+
+ if (!$translator->loadLocaleFromRequest()) {
+ if (Config::get('rainlab.translate::prefixDefaultLocale')) {
+ $translator->loadLocaleFromSession();
+ } else {
+ $translator->setLocale($translator->getDefaultLocale());
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/plugins/rainlab/translate/classes/MLCmsObject.php b/plugins/rainlab/translate/classes/MLCmsObject.php
new file mode 100644
index 0000000..2dce2a0
--- /dev/null
+++ b/plugins/rainlab/translate/classes/MLCmsObject.php
@@ -0,0 +1,56 @@
+theme);
+ }
+
+ public static function findLocale($locale, $page)
+ {
+ return static::forLocale($locale, $page)->find($page->fileName);
+ }
+
+ /**
+ * Returns the directory name corresponding to the object type.
+ * For pages the directory name is "pages", for layouts - "layouts", etc.
+ * @return string
+ */
+ public function getObjectTypeDirName()
+ {
+ $dirName = static::$parent->getObjectTypeDirName();
+
+ if (strlen(static::$locale)) {
+ $dirName .= '-' . static::$locale;
+ }
+
+ return $dirName;
+ }
+}
diff --git a/plugins/rainlab/translate/classes/MLContent.php b/plugins/rainlab/translate/classes/MLContent.php
new file mode 100644
index 0000000..0402d8d
--- /dev/null
+++ b/plugins/rainlab/translate/classes/MLContent.php
@@ -0,0 +1,70 @@
+exists) {
+ return null;
+ }
+
+ $fileName = $page->getOriginal('fileName') ?: $page->fileName;
+
+ $fileName = static::addLocaleToFileName($fileName, $locale);
+
+ return static::forLocale($locale, $page)->find($fileName);
+ }
+
+ /**
+ * Returns the directory name corresponding to the object type.
+ * Content does not use localized sub directories, but as file suffix instead.
+ * @return string
+ */
+ public function getObjectTypeDirName()
+ {
+ return static::$parent->getObjectTypeDirName();
+ }
+
+ /**
+ * Splice in the locale when setting the file name.
+ * @param mixed $value
+ */
+ public function setFileNameAttribute($value)
+ {
+ $value = static::addLocaleToFileName($value, static::$locale);
+
+ parent::setFileNameAttribute($value);
+ }
+
+ /**
+ * Splice the active locale in to the filename
+ * - content.htm -> content.en.htm
+ */
+ protected static function addLocaleToFileName($fileName, $locale)
+ {
+ /*
+ * Check locale not already present
+ */
+ $parts = explode('.', $fileName);
+ array_shift($parts);
+
+ foreach ($parts as $part) {
+ if (trim($part) === $locale) {
+ return $fileName;
+ }
+ }
+
+ return substr_replace($fileName, '.'.$locale, strrpos($fileName, '.'), 0);
+ }
+}
diff --git a/plugins/rainlab/translate/classes/MLStaticPage.php b/plugins/rainlab/translate/classes/MLStaticPage.php
new file mode 100644
index 0000000..0fba770
--- /dev/null
+++ b/plugins/rainlab/translate/classes/MLStaticPage.php
@@ -0,0 +1,118 @@
+getPlaceholdersAttribute();
+ }
+
+ /**
+ * Parses the page placeholder {% put %} tags and extracts the placeholder values.
+ * @return array Returns an associative array of the placeholder names and values.
+ */
+ public function getPlaceholdersAttribute()
+ {
+ if (!strlen($this->code)) {
+ return [];
+ }
+
+ if ($placeholders = array_get($this->attributes, 'placeholders')) {
+ return $placeholders;
+ }
+
+ $bodyNode = $this->getTwigNodeTree($this->code)->getNode('body')->getNode(0);
+ if ($bodyNode instanceof \Cms\Twig\PutNode) {
+ $bodyNode = [$bodyNode];
+ }
+
+ $result = [];
+ foreach ($bodyNode as $node) {
+ if (!$node instanceof \Cms\Twig\PutNode) {
+ continue;
+ }
+
+ // October CMS v2.2 and above
+ if (class_exists('System') && version_compare(\System::VERSION, '2.1') === 1) {
+ $names = $node->getNode('names');
+ $values = $node->getNode('values');
+ $isCapture = $node->getAttribute('capture');
+ if ($isCapture) {
+ $name = $names->getNode(0);
+ $result[$name->getAttribute('name')] = trim($values->getAttribute('data'));
+ }
+ }
+ // Legacy PutNode support
+ else {
+ $values = $node->getNode('body');
+ $result[$node->getAttribute('name')] = trim($values->getAttribute('data'));
+ }
+ }
+
+ $this->attributes['placeholders'] = $result;
+
+ return $result;
+ }
+
+ /**
+ * Takes an array of placeholder data (key: code, value: content) and renders
+ * it as a single string of Twig markup against the "code" attribute.
+ * @param array $value
+ * @return void
+ */
+ public function setPlaceholdersAttribute($value)
+ {
+ if (!is_array($value)) {
+ return;
+ }
+
+ $placeholders = $value;
+ $result = '';
+
+ foreach ($placeholders as $code => $content) {
+ if (!strlen($content)) {
+ continue;
+ }
+
+ $result .= '{% put '.$code.' %}'.PHP_EOL;
+ $result .= $content.PHP_EOL;
+ $result .= '{% endput %}'.PHP_EOL;
+ $result .= PHP_EOL;
+ }
+
+ $this->attributes['code'] = trim($result);
+ $this->attributes['placeholders'] = $placeholders;
+ }
+
+ /**
+ * Disables safe mode check for static pages.
+ *
+ * This allows developers to use placeholders in layouts even if safe mode is enabled.
+ *
+ * @return void
+ */
+ protected function checkSafeMode()
+ {
+ }
+}
diff --git a/plugins/rainlab/translate/classes/ThemeScanner.php b/plugins/rainlab/translate/classes/ThemeScanner.php
new file mode 100644
index 0000000..3ad0929
--- /dev/null
+++ b/plugins/rainlab/translate/classes/ThemeScanner.php
@@ -0,0 +1,229 @@
+scanForMessages();
+
+ /**
+ * @event rainlab.translate.themeScanner.afterScan
+ * Fires after theme scanning.
+ *
+ * Example usage:
+ *
+ * Event::listen('rainlab.translate.themeScanner.afterScan', function (ThemeScanner $scanner) {
+ * // added an extra scan. Add generation files...
+ * });
+ *
+ */
+ Event::fire('rainlab.translate.themeScanner.afterScan', [$obj]);
+ }
+
+ /**
+ * Scans theme templates and config for messages.
+ * @return void
+ */
+ public function scanForMessages()
+ {
+ // Set all messages initially as being not found. The scanner later
+ // sets the entries it finds as found.
+ Message::query()->update(['found' => false]);
+
+ $this->scanThemeConfigForMessages();
+ $this->scanThemeTemplatesForMessages();
+ $this->scanMailTemplatesForMessages();
+ }
+
+ /**
+ * Scans the theme configuration for defined messages
+ * @return void
+ */
+ public function scanThemeConfigForMessages($themeCode = null)
+ {
+ if (!$themeCode) {
+ $theme = Theme::getActiveTheme();
+
+ if (!$theme) {
+ return;
+ }
+ }
+ else {
+ if (!Theme::exists($themeCode)) {
+ return;
+ }
+
+ $theme = Theme::load($themeCode);
+ }
+
+ // October v2.0
+ if (class_exists('System') && $theme->hasParentTheme()) {
+ $parentTheme = $theme->getParentTheme();
+
+ try {
+ if (!$this->scanThemeConfigForMessagesInternal($theme)) {
+ $this->scanThemeConfigForMessagesInternal($parentTheme);
+ }
+ }
+ catch (Exception $ex) {
+ $this->scanThemeConfigForMessagesInternal($parentTheme);
+ }
+ }
+ else {
+ $this->scanThemeConfigForMessagesInternal($theme);
+ }
+ }
+
+ /**
+ * scanThemeConfigForMessagesInternal
+ */
+ protected function scanThemeConfigForMessagesInternal(Theme $theme)
+ {
+ $config = $theme->getConfigArray('translate');
+
+ if (!count($config)) {
+ return false;
+ }
+
+ $translator = Translator::instance();
+ $keys = [];
+
+ foreach ($config as $locale => $messages) {
+ if (is_string($messages)) {
+ // $message is a yaml filename, load the yaml file
+ $messages = $theme->getConfigArray('translate.'.$locale);
+ }
+ $keys = array_merge($keys, array_keys($messages));
+ }
+
+ Message::importMessages($keys);
+
+ foreach ($config as $locale => $messages) {
+ if (is_string($messages)) {
+ // $message is a yaml filename, load the yaml file
+ $messages = $theme->getConfigArray('translate.'.$locale);
+ }
+ Message::importMessageCodes($messages, $locale);
+ }
+ }
+
+ /**
+ * Scans the theme templates for message references.
+ * @return void
+ */
+ public function scanThemeTemplatesForMessages()
+ {
+ $messages = [];
+
+ foreach (Layout::all() as $layout) {
+ $messages = array_merge($messages, $this->parseContent($layout->markup));
+ }
+
+ foreach (Page::all() as $page) {
+ $messages = array_merge($messages, $this->parseContent($page->markup));
+ }
+
+ foreach (Partial::all() as $partial) {
+ $messages = array_merge($messages, $this->parseContent($partial->markup));
+ }
+
+ Message::importMessages($messages);
+ }
+
+ /**
+ * Scans the mail templates for message references.
+ * @return void
+ */
+ public function scanMailTemplatesForMessages()
+ {
+ $messages = [];
+
+ foreach (MailTemplate::allTemplates() as $mailTemplate) {
+ $messages = array_merge($messages, $this->parseContent($mailTemplate->subject));
+ $messages = array_merge($messages, $this->parseContent($mailTemplate->content_html));
+ }
+
+ Message::importMessages($messages);
+ }
+
+ /**
+ * Parse the known language tag types in to messages.
+ * @param string $content
+ * @return array
+ */
+ protected function parseContent($content)
+ {
+ $messages = [];
+ $messages = array_merge($messages, $this->processStandardTags($content));
+
+ return $messages;
+ }
+
+ /**
+ * Process standard language filter tag (_|)
+ * @param string $content
+ * @return array
+ */
+ protected function processStandardTags($content)
+ {
+ $messages = [];
+
+ /*
+ * Regex used:
+ *
+ * {{'AJAX framework'|_}}
+ * {{\s*'([^'])+'\s*[|]\s*_\s*}}
+ *
+ * {{'AJAX framework'|_(variables)}}
+ * {{\s*'([^'])+'\s*[|]\s*_\s*\([^\)]+\)\s*}}
+ */
+
+ $quoteChar = preg_quote("'");
+
+ preg_match_all('#{{\s*'.$quoteChar.'([^'.$quoteChar.']+)'.$quoteChar.'\s*[|]\s*_\s*(?:[|].+)?}}#', $content, $match);
+ if (isset($match[1])) {
+ $messages = array_merge($messages, $match[1]);
+ }
+
+ preg_match_all('#{{\s*'.$quoteChar.'([^'.$quoteChar.']+)'.$quoteChar.'\s*[|]\s*_\s*\([^\)]+\)\s*}}#', $content, $match);
+ if (isset($match[1])) {
+ $messages = array_merge($messages, $match[1]);
+ }
+
+ $quoteChar = preg_quote('"');
+
+ preg_match_all('#{{\s*'.$quoteChar.'([^'.$quoteChar.']+)'.$quoteChar.'\s*[|]\s*_\s*(?:[|].+)?}}#', $content, $match);
+ if (isset($match[1])) {
+ $messages = array_merge($messages, $match[1]);
+ }
+
+ preg_match_all('#{{\s*'.$quoteChar.'([^'.$quoteChar.']+)'.$quoteChar.'\s*[|]\s*_\s*\([^\)]+\)\s*}}#', $content, $match);
+ if (isset($match[1])) {
+ $messages = array_merge($messages, $match[1]);
+ }
+
+ return $messages;
+ }
+}
diff --git a/plugins/rainlab/translate/classes/TranslatableBehavior.php b/plugins/rainlab/translate/classes/TranslatableBehavior.php
new file mode 100644
index 0000000..3ae9e65
--- /dev/null
+++ b/plugins/rainlab/translate/classes/TranslatableBehavior.php
@@ -0,0 +1,494 @@
+model = $model;
+
+ $this->initTranslatableContext();
+
+ $this->model->bindEvent('model.beforeGetAttribute', function ($key) use ($model) {
+ if ($this->isTranslatable($key)) {
+ $value = $this->getAttributeTranslated($key);
+ if ($model->hasGetMutator($key)) {
+ $method = 'get' . Str::studly($key) . 'Attribute';
+ $value = $model->{$method}($value);
+ }
+ return $value;
+ }
+ });
+
+ $this->model->bindEvent('model.beforeSetAttribute', function ($key, $value) use ($model) {
+ if ($this->isTranslatable($key)) {
+ $value = $this->setAttributeTranslated($key, $value);
+ if ($model->hasSetMutator($key)) {
+ $method = 'set' . Str::studly($key) . 'Attribute';
+ $value = $model->{$method}($value);
+ }
+ return $value;
+ }
+ });
+
+ $this->model->bindEvent('model.saveInternal', function() {
+ $this->syncTranslatableAttributes();
+ });
+ }
+
+ /**
+ * Initializes this class, sets the default language code to use.
+ * @return void
+ */
+ public function initTranslatableContext()
+ {
+ $translate = Translator::instance();
+ $this->translatableContext = $translate->getLocale();
+ $this->translatableDefault = $translate->getDefaultLocale();
+ }
+
+ /**
+ * Checks if an attribute should be translated or not.
+ * @param string $key
+ * @return boolean
+ */
+ public function isTranslatable($key)
+ {
+ if ($key === 'translatable' || $this->translatableDefault == $this->translatableContext) {
+ return false;
+ }
+
+ return in_array($key, $this->model->getTranslatableAttributes());
+ }
+
+ /**
+ * Disables translation fallback locale.
+ * @return self
+ */
+ public function noFallbackLocale()
+ {
+ $this->translatableUseFallback = false;
+
+ return $this->model;
+ }
+
+ /**
+ * Enables translation fallback locale.
+ * @return self
+ */
+ public function withFallbackLocale()
+ {
+ $this->translatableUseFallback = true;
+
+ return $this->model;
+ }
+
+ /**
+ * Returns a translated attribute value.
+ *
+ * The base value must come from 'attributes' on the model otherwise the process
+ * can possibly loop back to this event, then method triggered by __get() magic.
+ *
+ * @param string $key
+ * @param string $locale
+ * @return string
+ */
+ public function getAttributeTranslated($key, $locale = null)
+ {
+ if ($locale == null) {
+ $locale = $this->translatableContext;
+ }
+
+ /*
+ * Result should not return NULL to successfully hook beforeGetAttribute event
+ */
+ $result = '';
+
+ /*
+ * Default locale
+ */
+ if ($locale == $this->translatableDefault) {
+ $result = $this->getAttributeFromData($this->model->attributes, $key);
+ }
+ /*
+ * Other locale
+ */
+ else {
+ if (!array_key_exists($locale, $this->translatableAttributes)) {
+ $this->loadTranslatableData($locale);
+ }
+
+ if ($this->hasTranslation($key, $locale)) {
+ $result = $this->getAttributeFromData($this->translatableAttributes[$locale], $key);
+ }
+ elseif ($this->translatableUseFallback) {
+ $result = $this->getAttributeFromData($this->model->attributes, $key);
+ }
+ }
+
+ /*
+ * Handle jsonable attributes, default locale may return the value as a string
+ */
+ if (
+ is_string($result) &&
+ method_exists($this->model, 'isJsonable') &&
+ $this->model->isJsonable($key)
+ ) {
+ $result = json_decode($result, true);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns all translated attribute values.
+ * @param string $locale
+ * @return array
+ */
+ public function getTranslateAttributes($locale)
+ {
+ if (!array_key_exists($locale, $this->translatableAttributes)) {
+ $this->loadTranslatableData($locale);
+ }
+
+ return array_get($this->translatableAttributes, $locale, []);
+ }
+
+ /**
+ * Returns whether the attribute is translatable (has a translation) for the given locale.
+ * @param string $key
+ * @param string $locale
+ * @return bool
+ */
+ public function hasTranslation($key, $locale)
+ {
+ /*
+ * If the default locale is passed, the attributes are retreived from the model,
+ * otherwise fetch the attributes from the $translatableAttributes property
+ */
+ if ($locale == $this->translatableDefault) {
+ $translatableAttributes = $this->model->attributes;
+ }
+ else {
+ /*
+ * Ensure that the translatableData has been loaded
+ * @see https://github.com/rainlab/translate-plugin/issues/302
+ */
+ if (!isset($this->translatableAttributes[$locale])) {
+ $this->loadTranslatableData($locale);
+ }
+
+ $translatableAttributes = $this->translatableAttributes[$locale];
+ }
+
+ return !!$this->getAttributeFromData($translatableAttributes, $key);
+ }
+
+ /**
+ * Sets a translated attribute value.
+ * @param string $key Attribute
+ * @param string $value Value to translate
+ * @return string Translated value
+ */
+ public function setAttributeTranslated($key, $value, $locale = null)
+ {
+ if ($locale == null) {
+ $locale = $this->translatableContext;
+ }
+
+ if ($locale == $this->translatableDefault) {
+ return $this->setAttributeFromData($this->model->attributes, $key, $value);
+ }
+
+ if (!array_key_exists($locale, $this->translatableAttributes)) {
+ $this->loadTranslatableData($locale);
+ }
+
+ return $this->setAttributeFromData($this->translatableAttributes[$locale], $key, $value);
+ }
+
+ /**
+ * Restores the default language values on the model and
+ * stores the translated values in the attributes table.
+ * @return void
+ */
+ public function syncTranslatableAttributes()
+ {
+ /*
+ * Spin through the known locales, store the translations if necessary
+ */
+ $knownLocales = array_keys($this->translatableAttributes);
+ foreach ($knownLocales as $locale) {
+ if (!$this->isTranslateDirty(null, $locale)) {
+ continue;
+ }
+
+ $this->storeTranslatableData($locale);
+ }
+
+ /*
+ * Saving the default locale, no need to restore anything
+ */
+ if ($this->translatableContext == $this->translatableDefault) {
+ return;
+ }
+
+ /*
+ * Restore translatable values to models originals
+ */
+ $original = $this->model->getOriginal();
+ $attributes = $this->model->getAttributes();
+ $translatable = $this->model->getTranslatableAttributes();
+ $originalValues = array_intersect_key($original, array_flip($translatable));
+ $this->model->attributes = array_merge($attributes, $originalValues);
+ }
+
+ /**
+ * Changes the active language for this model
+ * @param string $context
+ * @return void
+ */
+ public function translateContext($context = null)
+ {
+ if ($context === null) {
+ return $this->translatableContext;
+ }
+
+ $this->translatableContext = $context;
+ }
+
+ /**
+ * Shorthand for translateContext method, and chainable.
+ * @param string $context
+ * @return self
+ */
+ public function lang($context = null)
+ {
+ $this->translateContext($context);
+
+ return $this->model;
+ }
+
+ /**
+ * Checks if this model has transatable attributes.
+ * @return true
+ */
+ public function hasTranslatableAttributes()
+ {
+ return is_array($this->model->translatable) &&
+ count($this->model->translatable) > 0;
+ }
+
+ /**
+ * Returns a collection of fields that will be hashed.
+ * @return array
+ */
+ public function getTranslatableAttributes()
+ {
+ $translatable = [];
+
+ if (!is_array($this->model->translatable)) {
+ return [];
+ }
+
+ foreach ($this->model->translatable as $attribute) {
+ $translatable[] = is_array($attribute) ? array_shift($attribute) : $attribute;
+ }
+
+ return $translatable;
+ }
+
+ /**
+ * Returns the defined options for a translatable attribute.
+ * @return array
+ */
+ public function getTranslatableAttributesWithOptions()
+ {
+ $attributes = [];
+
+ foreach ($this->model->translatable as $options) {
+ if (!is_array($options)) {
+ continue;
+ }
+
+ $attributeName = array_shift($options);
+
+ $attributes[$attributeName] = $options;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Determine if the model or a given translated attribute has been modified.
+ * @param string|null $attribute
+ * @return bool
+ */
+ public function isTranslateDirty($attribute = null, $locale = null)
+ {
+ $dirty = $this->getTranslateDirty($locale);
+
+ if (is_null($attribute)) {
+ return count($dirty) > 0;
+ }
+ else {
+ return array_key_exists($attribute, $dirty);
+ }
+ }
+
+ /**
+ * Get the locales that have changed, if any
+ *
+ * @return array
+ */
+ public function getDirtyLocales()
+ {
+ $dirtyLocales = [];
+ $knownLocales = array_keys($this->translatableAttributes);
+ foreach ($knownLocales as $locale) {
+ if ($this->isTranslateDirty(null, $locale)) {
+ $dirtyLocales[] = $locale;
+ }
+ }
+
+ return $dirtyLocales;
+ }
+
+ /**
+ * Get the original values of the translated attributes.
+ * @param string|null $locale If `null`, the method will get the original data for all locales.
+ * @return array|null Returns locale data as an array, or `null` if an invalid locale is specified.
+ */
+ public function getTranslatableOriginals($locale = null)
+ {
+ if (!$locale) {
+ return $this->translatableOriginals;
+ } else {
+ return $this->translatableOriginals[$locale] ?? null;
+ }
+ }
+
+ /**
+ * Get the translated attributes that have been changed since last sync.
+ * @return array
+ */
+ public function getTranslateDirty($locale = null)
+ {
+ if (!$locale) {
+ $locale = $this->translatableContext;
+ }
+
+ if (!array_key_exists($locale, $this->translatableAttributes)) {
+ return [];
+ }
+
+ if (!array_key_exists($locale, $this->translatableOriginals)) {
+ return $this->translatableAttributes[$locale]; // All dirty
+ }
+
+ $dirty = [];
+
+ foreach ($this->translatableAttributes[$locale] as $key => $value) {
+
+ if (!array_key_exists($key, $this->translatableOriginals[$locale])) {
+ $dirty[$key] = $value;
+ }
+ elseif ($value != $this->translatableOriginals[$locale][$key]) {
+ $dirty[$key] = $value;
+ }
+ }
+
+ return $dirty;
+ }
+
+ /**
+ * Extracts a attribute from a model/array with nesting support.
+ * @param mixed $data
+ * @param string $attribute
+ * @return mixed
+ */
+ protected function getAttributeFromData($data, $attribute)
+ {
+ $keyArray = HtmlHelper::nameToArray($attribute);
+
+ return array_get($data, implode('.', $keyArray));
+ }
+
+ /**
+ * Sets an attribute from a model/array with nesting support.
+ * @param mixed $data
+ * @param string $attribute
+ * @return mixed
+ */
+ protected function setAttributeFromData(&$data, $attribute, $value)
+ {
+ $keyArray = HtmlHelper::nameToArray($attribute);
+
+ array_set($data, implode('.', $keyArray), $value);
+
+ return $value;
+ }
+
+ /**
+ * Saves the translation data for the model.
+ * @param string $locale
+ * @return void
+ */
+ abstract protected function storeTranslatableData($locale = null);
+
+ /**
+ * Loads the translation data from the model.
+ * @param string $locale
+ * @return array
+ */
+ abstract protected function loadTranslatableData($locale = null);
+}
diff --git a/plugins/rainlab/translate/classes/Translator.php b/plugins/rainlab/translate/classes/Translator.php
new file mode 100644
index 0000000..de9dd70
--- /dev/null
+++ b/plugins/rainlab/translate/classes/Translator.php
@@ -0,0 +1,259 @@
+defaultLocale = $this->isConfigured() ? array_get(Locale::getDefault(), 'code', 'en') : 'en';
+ $this->activeLocale = $this->defaultLocale;
+ }
+
+ /**
+ * Changes the locale in the application and optionally stores it in the session.
+ * @param string $locale Locale to use
+ * @param boolean $remember Set to false to not store in the session.
+ * @return boolean Returns true if the locale exists and is set.
+ */
+ public function setLocale($locale, $remember = true)
+ {
+ if (!Locale::isValid($locale)) {
+ return false;
+ }
+
+ App::setLocale($locale);
+
+ $this->activeLocale = $locale;
+
+ if ($remember) {
+ $this->setSessionLocale($locale);
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the active locale set by this instance.
+ * @param boolean $fromSession Look in the session.
+ * @return string
+ */
+ public function getLocale($fromSession = false)
+ {
+ if ($fromSession && ($locale = $this->getSessionLocale())) {
+ return $locale;
+ }
+
+ return $this->activeLocale;
+ }
+
+ /**
+ * Returns the default locale as set by the application.
+ * @return string
+ */
+ public function getDefaultLocale()
+ {
+ return $this->defaultLocale;
+ }
+
+ /**
+ * Check if this plugin is installed and the database is available,
+ * stores the result in the session for efficiency.
+ * @return boolean
+ */
+ public function isConfigured()
+ {
+ if ($this->isConfigured !== null) {
+ return $this->isConfigured;
+ }
+
+ if (Session::has(self::SESSION_CONFIGURED)) {
+ $result = true;
+ }
+ elseif (App::hasDatabase() && Schema::hasTable('rainlab_translate_locales')) {
+ Session::put(self::SESSION_CONFIGURED, true);
+ $result = true;
+ }
+ else {
+ $result = false;
+ }
+
+ return $this->isConfigured = $result;
+ }
+
+ //
+ // Request handling
+ //
+
+ /**
+ * handleLocaleRoute will check if the route contains a translated locale prefix (/en/)
+ * and return that locale to be registered with the router.
+ * @return string
+ */
+ public function handleLocaleRoute()
+ {
+ if (Config::get('rainlab.translate::disableLocalePrefixRoutes', false)) {
+ return '';
+ }
+
+ if (App::runningInBackend()) {
+ return '';
+ }
+
+ if (!$this->isConfigured()) {
+ return '';
+ }
+
+ if (!$this->loadLocaleFromRequest()) {
+ return '';
+ }
+
+ $locale = $this->getLocale();
+ if (!$locale) {
+ return '';
+ }
+
+ return $locale;
+ }
+
+ /**
+ * Sets the locale based on the first URI segment.
+ * @return bool
+ */
+ public function loadLocaleFromRequest()
+ {
+ $locale = Request::segment(1);
+
+ if (!Locale::isValid($locale)) {
+ return false;
+ }
+
+ $this->setLocale($locale);
+ return true;
+ }
+
+ /**
+ * Returns the current path prefixed with language code.
+ *
+ * @param string $locale optional language code, default to the system default language
+ * @return string
+ */
+ public function getCurrentPathInLocale($locale = null)
+ {
+ return $this->getPathInLocale(Request::path(), $locale);
+ }
+
+ /**
+ * Returns the path prefixed with language code.
+ *
+ * @param string $path Path to rewrite, already translate, with or without locale prefixed
+ * @param string $locale optional language code, default to the system default language
+ * @param boolean $prefixDefaultLocale should we prefix the path when the locale = default locale
+ * @return string
+ */
+ public function getPathInLocale($path, $locale = null, $prefixDefaultLocale = null)
+ {
+ $prefixDefaultLocale = (is_null($prefixDefaultLocale))
+ ? Config::get('rainlab.translate::prefixDefaultLocale')
+ : $prefixDefaultLocale;
+
+ $segments = explode('/', $path);
+
+ $segments = array_values(array_filter($segments, function ($v) {
+ return $v != '';
+ }));
+
+ if (is_null($locale) || !Locale::isValid($locale)) {
+ $locale = $this->defaultLocale;
+ }
+
+ if (count($segments) == 0 || Locale::isValid($segments[0])) {
+ $segments[0] = $locale;
+ } else {
+ array_unshift($segments, $locale);
+ }
+
+ // If we don't want te default locale to be prefixed
+ // and the first segment equals the default locale
+ if (
+ !$prefixDefaultLocale &&
+ isset($segments[0]) &&
+ $segments[0] == $this->defaultLocale
+ ) {
+ // Remove the default locale
+ array_shift($segments);
+ };
+
+ return htmlspecialchars(implode('/', $segments), ENT_QUOTES, 'UTF-8');
+ }
+
+ //
+ // Session handling
+ //
+
+ /**
+ * Looks at the session storage to find a locale.
+ * @return bool
+ */
+ public function loadLocaleFromSession()
+ {
+ $locale = $this->getSessionLocale();
+
+ if (!$locale) {
+ return false;
+ }
+
+ $this->setLocale($locale);
+ return true;
+ }
+
+ protected function getSessionLocale()
+ {
+ if (!Session::has(self::SESSION_LOCALE)) {
+ return null;
+ }
+
+ return Session::get(self::SESSION_LOCALE);
+ }
+
+ protected function setSessionLocale($locale)
+ {
+ Session::put(self::SESSION_LOCALE, $locale);
+ }
+}
diff --git a/plugins/rainlab/translate/components/AlternateHrefLangElements.php b/plugins/rainlab/translate/components/AlternateHrefLangElements.php
new file mode 100644
index 0000000..687fd49
--- /dev/null
+++ b/plugins/rainlab/translate/components/AlternateHrefLangElements.php
@@ -0,0 +1,82 @@
+ 'rainlab.translate::lang.alternate_hreflang.component_name',
+ 'description' => 'rainlab.translate::lang.alternate_hreflang.component_description'
+ ];
+ }
+
+ /**
+ * locales
+ */
+ public function locales()
+ {
+ // Available locales
+ $locales = collect(LocaleModel::listEnabled());
+
+ // Transform it to contain the new urls
+ $locales->transform(function ($item, $key) {
+ return $this->retrieveLocalizedUrl($key);
+ });
+
+ return $locales->toArray();
+ }
+
+ /**
+ * retrieveLocalizedUrl
+ */
+ protected function retrieveLocalizedUrl($locale)
+ {
+ $translator = Translator::instance();
+ $page = $this->getPage();
+
+ /*
+ * Static Page
+ */
+ if (isset($page->apiBag['staticPage'])) {
+ $staticPage = $page->apiBag['staticPage'];
+ $staticPage->rewriteTranslatablePageUrl($locale);
+ $localeUrl = array_get($staticPage->attributes, 'viewBag.url');
+ }
+ /*
+ * CMS Page
+ */
+ else {
+ $page->rewriteTranslatablePageUrl($locale);
+ $params = $this->getRouter()->getParameters();
+
+ $translatedParams = Event::fire('translate.localePicker.translateParams', [
+ $page,
+ $params,
+ $this->oldLocale,
+ $locale
+ ], true);
+
+ if ($translatedParams) {
+ $params = $translatedParams;
+ }
+
+ $router = new RainRouter;
+ $localeUrl = $router->urlFromPattern($page->url, $params);
+ }
+
+ return $translator->getPathInLocale($localeUrl, $locale);
+ }
+
+}
diff --git a/plugins/rainlab/translate/components/LocalePicker.php b/plugins/rainlab/translate/components/LocalePicker.php
new file mode 100644
index 0000000..c93b55b
--- /dev/null
+++ b/plugins/rainlab/translate/components/LocalePicker.php
@@ -0,0 +1,226 @@
+ 'rainlab.translate::lang.locale_picker.component_name',
+ 'description' => 'rainlab.translate::lang.locale_picker.component_description',
+ ];
+ }
+
+ public function defineProperties()
+ {
+ return [
+ 'forceUrl' => [
+ 'title' => 'Force URL schema',
+ 'description' => 'Always prefix the URL with a language code.',
+ 'default' => 0,
+ 'type' => 'checkbox'
+ ],
+ ];
+ }
+
+ public function init()
+ {
+ $this->translator = Translator::instance();
+ }
+
+ public function onRun()
+ {
+ if ($redirect = $this->redirectForceUrl()) {
+ return $redirect;
+ }
+
+ $this->page['locales'] = $this->locales = LocaleModel::listEnabled();
+ $this->page['activeLocale'] = $this->activeLocale = $this->translator->getLocale();
+ $this->page['activeLocaleName'] = $this->activeLocaleName = array_get($this->locales, $this->activeLocale);
+ }
+
+ public function onSwitchLocale()
+ {
+ if (!$locale = post('locale')) {
+ return;
+ }
+
+ // Remember the current locale before switching to the requested one
+ $this->oldLocale = $this->translator->getLocale();
+
+ $this->translator->setLocale($locale);
+
+ $pageUrl = $this->withPreservedQueryString($this->makeLocaleUrlFromPage($locale), $locale);
+ if ($this->property('forceUrl')) {
+ return Redirect::to($this->translator->getPathInLocale($pageUrl, $locale));
+ }
+
+ return Redirect::to($pageUrl);
+ }
+
+ protected function redirectForceUrl()
+ {
+ if (
+ Request::ajax() ||
+ !$this->property('forceUrl') ||
+ $this->translator->loadLocaleFromRequest()
+ ) {
+ return;
+ }
+
+ $prefixDefaultLocale = Config::get('rainlab.translate::prefixDefaultLocale');
+ $locale = $this->translator->getLocale(false)
+ ?: $this->translator->getDefaultLocale();
+
+ if ($prefixDefaultLocale) {
+ return Redirect::to(
+ $this->withPreservedQueryString(
+ $this->translator->getCurrentPathInLocale($locale),
+ $locale
+ )
+ );
+ } elseif ( $locale == $this->translator->getDefaultLocale()) {
+ return;
+ } else {
+ $this->translator->setLocale($this->translator->getDefaultLocale());
+ return;
+ }
+
+ }
+
+ /**
+ * Returns the URL from a page object, including current parameter values.
+ * @return string
+ */
+ protected function makeLocaleUrlFromPage($locale)
+ {
+ $page = $this->getPage();
+
+ /*
+ * Static Page
+ */
+ if (isset($page->apiBag['staticPage'])) {
+ $staticPage = $page->apiBag['staticPage'];
+
+ $staticPage->rewriteTranslatablePageUrl($locale);
+
+ $localeUrl = array_get($staticPage->attributes, 'viewBag.url');
+ }
+ /*
+ * CMS Page
+ */
+ else {
+ $page->rewriteTranslatablePageUrl($locale);
+
+ $router = new RainRouter;
+
+ $params = $this->getRouter()->getParameters();
+
+ /**
+ * @event translate.localePicker.translateParams
+ * Enables manipulating the URL parameters
+ *
+ * You will have access to the page object, the old and new locale and the URL parameters.
+ *
+ * Example usage:
+ *
+ * Event::listen('translate.localePicker.translateParams', function($page, $params, $oldLocale, $newLocale) {
+ * if ($page->baseFileName == 'your-page-filename') {
+ * return YourModel::translateParams($params, $oldLocale, $newLocale);
+ * }
+ * });
+ *
+ */
+ $translatedParams = Event::fire('translate.localePicker.translateParams', [
+ $page,
+ $params,
+ $this->oldLocale,
+ $locale
+ ], true);
+
+ if ($translatedParams) {
+ $params = $translatedParams;
+ }
+
+ $localeUrl = $router->urlFromPattern($page->url, $params);
+ }
+
+ return $localeUrl;
+ }
+
+ /**
+ * Makes sure to add any existing query string to the redirect url.
+ *
+ * @param $pageUrl
+ * @param $locale
+ *
+ * @return string
+ */
+ protected function withPreservedQueryString($pageUrl, $locale)
+ {
+ $page = $this->getPage();
+ $query = request()->query();
+
+ /**
+ * @event translate.localePicker.translateQuery
+ * Enables manipulating the URL query parameters
+ *
+ * You will have access to the page object, the old and new locale and the URL query parameters.
+ *
+ * Example usage:
+ *
+ * Event::listen('translate.localePicker.translateQuery', function($page, $params, $oldLocale, $newLocale) {
+ * if ($page->baseFileName == 'your-page-filename') {
+ * return YourModel::translateParams($params, $oldLocale, $newLocale);
+ * }
+ * });
+ *
+ */
+ $translatedQuery = Event::fire('translate.localePicker.translateQuery', [
+ $page,
+ $query,
+ $this->oldLocale,
+ $locale
+ ], true);
+
+ $query = http_build_query($translatedQuery ?: $query);
+
+ return $query ? $pageUrl . '?' . $query : $pageUrl;
+ }
+}
diff --git a/plugins/rainlab/translate/components/alternatehreflangelements/default.htm b/plugins/rainlab/translate/components/alternatehreflangelements/default.htm
new file mode 100644
index 0000000..e7b5fd8
--- /dev/null
+++ b/plugins/rainlab/translate/components/alternatehreflangelements/default.htm
@@ -0,0 +1,3 @@
+{% for locale, alternateUrl in __SELF__.locales %}
+
+{% endfor %}
diff --git a/plugins/rainlab/translate/components/localepicker/default.htm b/plugins/rainlab/translate/components/localepicker/default.htm
new file mode 100644
index 0000000..90c3444
--- /dev/null
+++ b/plugins/rainlab/translate/components/localepicker/default.htm
@@ -0,0 +1,7 @@
+{{ form_open() }}
+
+{{ form_close() }}
\ No newline at end of file
diff --git a/plugins/rainlab/translate/composer.json b/plugins/rainlab/translate/composer.json
new file mode 100644
index 0000000..af34178
--- /dev/null
+++ b/plugins/rainlab/translate/composer.json
@@ -0,0 +1,25 @@
+{
+ "name": "rainlab/translate-plugin",
+ "type": "october-plugin",
+ "description": "Translate plugin for October CMS",
+ "homepage": "https://octobercms.com/plugin/rainlab-translate",
+ "keywords": ["october", "octobercms", "translate"],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Alexey Bobkov",
+ "email": "aleksey.bobkov@gmail.com",
+ "role": "Co-founder"
+ },
+ {
+ "name": "Samuel Georges",
+ "email": "daftspunky@gmail.com",
+ "role": "Co-founder"
+ }
+ ],
+ "require": {
+ "php": ">=5.5.9",
+ "composer/installers": "~1.0"
+ },
+ "minimum-stability": "dev"
+}
diff --git a/plugins/rainlab/translate/config/config.php b/plugins/rainlab/translate/config/config.php
new file mode 100644
index 0000000..d9c60fc
--- /dev/null
+++ b/plugins/rainlab/translate/config/config.php
@@ -0,0 +1,49 @@
+ env('TRANSLATE_FORCE_LOCALE', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Prefix the Default Locale
+ |--------------------------------------------------------------------------
+ |
+ | Specifies if the default locale be prefixed by the plugin.
+ |
+ */
+ 'prefixDefaultLocale' => env('TRANSLATE_PREFIX_LOCALE', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Timeout in Minutes
+ |--------------------------------------------------------------------------
+ |
+ | By default all translations are cached for 24 hours (1440 min).
+ | This setting allows to change that period with given amount of minutes.
+ |
+ | For example, 43200 for 30 days or 525600 for one year.
+ |
+ */
+ 'cacheTimeout' => env('TRANSLATE_CACHE_TIMEOUT', 1440),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Disable Locale Prefix Routes
+ |--------------------------------------------------------------------------
+ |
+ | Disables the automatically generated locale prefixed routes
+ | (i.e. /en/original-route) when enabled.
+ |
+ */
+ 'disableLocalePrefixRoutes' => env('TRANSLATE_DISABLE_PREFIX_ROUTES', false),
+
+];
diff --git a/plugins/rainlab/translate/console/ScanCommand.php b/plugins/rainlab/translate/console/ScanCommand.php
new file mode 100644
index 0000000..4d38604
--- /dev/null
+++ b/plugins/rainlab/translate/console/ScanCommand.php
@@ -0,0 +1,40 @@
+option('purge')) {
+ $this->output->writeln('Purging messages...');
+ Message::truncate();
+ }
+
+ ThemeScanner::scan();
+ $this->output->success('Messages scanned successfully.');
+ $this->output->note('You may need to run cache:clear for updated messages to take effect.');
+ }
+
+ protected function getArguments()
+ {
+ return [];
+ }
+
+ protected function getOptions()
+ {
+ return [
+ ['purge', 'null', InputOption::VALUE_NONE, 'First purge existing messages.', null],
+ ];
+ }
+}
diff --git a/plugins/rainlab/translate/controllers/Locales.php b/plugins/rainlab/translate/controllers/Locales.php
new file mode 100644
index 0000000..7f3be5d
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/Locales.php
@@ -0,0 +1,92 @@
+addJs('/plugins/rainlab/translate/assets/js/locales.js');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function listInjectRowClass($record, $definition = null)
+ {
+ if (!$record->is_enabled) {
+ return 'safe disabled';
+ }
+ }
+
+ public function onCreateForm()
+ {
+ $this->asExtension('FormController')->create();
+
+ return $this->makePartial('create_form');
+ }
+
+ public function onCreate()
+ {
+ LocaleModel::clearCache();
+ $this->asExtension('FormController')->create_onSave();
+
+ return $this->listRefresh();
+ }
+
+ public function onUpdateForm()
+ {
+ $this->asExtension('FormController')->update(post('record_id'));
+ $this->vars['recordId'] = post('record_id');
+
+ return $this->makePartial('update_form');
+ }
+
+ public function onUpdate()
+ {
+ LocaleModel::clearCache();
+ $this->asExtension('FormController')->update_onSave(post('record_id'));
+
+ return $this->listRefresh();
+ }
+
+ public function onDelete()
+ {
+ LocaleModel::clearCache();
+ $this->asExtension('FormController')->update_onDelete(post('record_id'));
+
+ return $this->listRefresh();
+ }
+
+ public function onReorder()
+ {
+ LocaleModel::clearCache();
+ $this->asExtension('ReorderController')->onReorder();
+ }
+}
diff --git a/plugins/rainlab/translate/controllers/Messages.php b/plugins/rainlab/translate/controllers/Messages.php
new file mode 100644
index 0000000..4f84286
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/Messages.php
@@ -0,0 +1,237 @@
+addJs('/plugins/rainlab/translate/assets/js/messages.js');
+ $this->addCss('/plugins/rainlab/translate/assets/css/messages.css');
+
+ $this->importColumns = MessageExport::getColumns();
+ $this->exportColumns = MessageExport::getColumns();
+ }
+
+ public function index()
+ {
+ $this->pageTitle = 'rainlab.translate::lang.messages.title';
+ $this->prepareTable();
+ }
+
+ public function onRefresh()
+ {
+ $this->prepareTable();
+ return ['#messagesContainer' => $this->makePartial('messages')];
+ }
+
+ public function onClearCache()
+ {
+ CacheHelper::clear();
+
+ Flash::success(Lang::get('rainlab.translate::lang.messages.clear_cache_success'));
+ }
+
+ public function onLoadScanMessagesForm()
+ {
+ return $this->makePartial('scan_messages_form');
+ }
+
+ public function onScanMessages()
+ {
+ if (post('purge_messages', false)) {
+ Message::truncate();
+ }
+
+ ThemeScanner::scan();
+
+ if (post('purge_deleted_messages', false)) {
+ Message::where('found', 0)->delete();
+ }
+
+ Flash::success(Lang::get('rainlab.translate::lang.messages.scan_messages_success'));
+
+ return $this->onRefresh();
+ }
+
+ public function prepareTable()
+ {
+ $fromCode = post('locale_from', null);
+ $toCode = post('locale_to', Locale::getDefault()->code);
+ $this->hideTranslated = post('hide_translated', false);
+
+ /*
+ * Page vars
+ */
+ $this->vars['hideTranslated'] = $this->hideTranslated;
+ $this->vars['defaultLocale'] = Locale::getDefault();
+ $this->vars['locales'] = Locale::all();
+ $this->vars['selectedFrom'] = $selectedFrom = Locale::findByCode($fromCode);
+ $this->vars['selectedTo'] = $selectedTo = Locale::findByCode($toCode);
+
+ /*
+ * Make table config, make default column read only
+ */
+ $config = $this->makeConfig('config_table.yaml');
+
+ if (!$selectedFrom) {
+ $config->columns['from']['readOnly'] = true;
+ }
+ if (!$selectedTo) {
+ $config->columns['to']['readOnly'] = true;
+ }
+
+ /*
+ * Make table widget
+ */
+ $widget = $this->makeWidget(\Backend\Widgets\Table::class, $config);
+ $widget->bindToController();
+
+ /*
+ * Populate data
+ */
+ $dataSource = $widget->getDataSource();
+
+ $dataSource->bindEvent('data.getRecords', function($offset, $count) use ($selectedFrom, $selectedTo) {
+ $messages = $this->listMessagesForDatasource([
+ 'offset' => $offset,
+ 'count' => $count
+ ]);
+
+ return $this->processTableData($messages, $selectedFrom, $selectedTo);
+ });
+
+ $dataSource->bindEvent('data.searchRecords', function($search, $offset, $count) use ($selectedFrom, $selectedTo) {
+ $messages = $this->listMessagesForDatasource([
+ 'search' => $search,
+ 'offset' => $offset,
+ 'count' => $count
+ ]);
+
+ return $this->processTableData($messages, $selectedFrom, $selectedTo);
+ });
+
+ $dataSource->bindEvent('data.getCount', function() {
+ return Message::count();
+ });
+
+ $dataSource->bindEvent('data.updateRecord', function($key, $data) {
+ $message = Message::find($key);
+ $this->updateTableData($message, $data);
+ CacheHelper::clear();
+ });
+
+ $dataSource->bindEvent('data.deleteRecord', function($key) {
+ if ($message = Message::find($key)) {
+ $message->delete();
+ }
+ });
+
+ $this->vars['table'] = $widget;
+ }
+
+ protected function isHideTranslated()
+ {
+ return post('hide_translated', false);
+ }
+
+ protected function listMessagesForDatasource($options = [])
+ {
+ extract(array_merge([
+ 'search' => null,
+ 'offset' => null,
+ 'count' => null,
+ ], $options));
+
+ $query = Message::orderBy('message_data','asc');
+
+ if ($search) {
+ $query = $query->searchWhere($search, ['message_data']);
+ }
+
+ if ($count) {
+ $query = $query->limit($count)->offset($offset);
+ }
+
+ return $query->get();
+ }
+
+ protected function processTableData($messages, $from, $to)
+ {
+ $fromCode = $from ? $from->code : null;
+ $toCode = $to ? $to->code : null;
+
+ $data = [];
+ foreach ($messages as $message) {
+ $toContent = $message->forLocale($toCode);
+ if ($this->hideTranslated && $toContent) {
+ continue;
+ }
+
+ $data[] = [
+ 'id' => $message->id,
+ 'code' => $message->code,
+ 'from' => $message->forLocale($fromCode),
+ 'to' => $toContent,
+ 'found' => $message->found ? '' : Lang::get('rainlab.translate::lang.messages.not_found'),
+ ];
+ }
+
+ return $data;
+ }
+
+ protected function updateTableData($message, $data)
+ {
+ if (!$message) {
+ return;
+ }
+
+ $fromCode = post('locale_from');
+ $toCode = post('locale_to');
+
+ // @todo This should be unified to a single save()
+ if ($fromCode) {
+ $fromValue = array_get($data, 'from');
+ if ($fromValue != $message->forLocale($fromCode)) {
+ $message->toLocale($fromCode, $fromValue);
+ }
+ }
+
+ if ($toCode) {
+ $toValue = array_get($data, 'to');
+ if ($toValue != $message->forLocale($toCode)) {
+ $message->toLocale($toCode, $toValue);
+ }
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/controllers/locales/_create_form.htm b/plugins/rainlab/translate/controllers/locales/_create_form.htm
new file mode 100644
index 0000000..e33ba27
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/locales/_create_form.htm
@@ -0,0 +1,55 @@
+= Form::open(['id' => 'createForm']) ?>
+
+
\ No newline at end of file
diff --git a/plugins/rainlab/translate/controllers/locales/_list_toolbar.htm b/plugins/rainlab/translate/controllers/locales/_list_toolbar.htm
new file mode 100644
index 0000000..126253b
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/locales/_list_toolbar.htm
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/translate/controllers/messages/_messages.htm b/plugins/rainlab/translate/controllers/messages/_messages.htm
new file mode 100644
index 0000000..87ed504
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/messages/_messages.htm
@@ -0,0 +1,10 @@
+
+
+ = $this->makePartial('table_headers') ?>
+
+
+ = $this->makePartial('table_toolbar') ?>
+
+
+ = $table->render() ?>
+
diff --git a/plugins/rainlab/translate/controllers/messages/_scan_messages_form.htm b/plugins/rainlab/translate/controllers/messages/_scan_messages_form.htm
new file mode 100644
index 0000000..101f82a
--- /dev/null
+++ b/plugins/rainlab/translate/controllers/messages/_scan_messages_form.htm
@@ -0,0 +1,80 @@
+
\ No newline at end of file
diff --git a/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_selector.htm b/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_selector.htm
new file mode 100644
index 0000000..fb2c0b6
--- /dev/null
+++ b/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_selector.htm
@@ -0,0 +1,18 @@
+
+
diff --git a/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_values.htm b/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_values.htm
new file mode 100644
index 0000000..ae24007
--- /dev/null
+++ b/plugins/rainlab/translate/traits/mlcontrol/partials/_locale_values.htm
@@ -0,0 +1,15 @@
+
+ $name): ?>
+ getLocaleValue($code);
+ $value = $this->isLocaleFieldJsonable() ? json_encode($value) : $value;
+ if (is_array($value)) $value = array_first($value);
+ ?>
+ getAttributes() ?>
+ />
+
diff --git a/plugins/rainlab/translate/updates/builder_table_update_rainlab_translate_locales.php b/plugins/rainlab/translate/updates/builder_table_update_rainlab_translate_locales.php
new file mode 100644
index 0000000..0c1f319
--- /dev/null
+++ b/plugins/rainlab/translate/updates/builder_table_update_rainlab_translate_locales.php
@@ -0,0 +1,44 @@
+integer('sort_order')->default(0);
+ });
+ }
+
+ $locales = Locale::all();
+ foreach($locales as $locale) {
+ $locale->sort_order = $locale->id;
+ $locale->save();
+ }
+ }
+
+ public function down()
+ {
+ if (!Schema::hasTable(self::TABLE_NAME)) {
+ return;
+ }
+
+ if (Schema::hasColumn(self::TABLE_NAME, 'sort_order')) {
+ Schema::table(self::TABLE_NAME, function($table)
+ {
+ $table->dropColumn(['sort_order']);
+ });
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/updates/create_attributes_table.php b/plugins/rainlab/translate/updates/create_attributes_table.php
new file mode 100644
index 0000000..7dc5b96
--- /dev/null
+++ b/plugins/rainlab/translate/updates/create_attributes_table.php
@@ -0,0 +1,27 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('locale')->index();
+ $table->string('model_id')->index()->nullable();
+ $table->string('model_type')->index()->nullable();
+ $table->mediumText('attribute_data')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_translate_attributes');
+ }
+
+}
diff --git a/plugins/rainlab/translate/updates/create_indexes_table.php b/plugins/rainlab/translate/updates/create_indexes_table.php
new file mode 100644
index 0000000..2e90ece
--- /dev/null
+++ b/plugins/rainlab/translate/updates/create_indexes_table.php
@@ -0,0 +1,28 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('locale')->index();
+ $table->string('model_id')->index()->nullable();
+ $table->string('model_type')->index()->nullable();
+ $table->string('item')->nullable()->index();
+ $table->mediumText('value')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_translate_indexes');
+ }
+
+}
diff --git a/plugins/rainlab/translate/updates/create_locales_table.php b/plugins/rainlab/translate/updates/create_locales_table.php
new file mode 100644
index 0000000..4d24e9e
--- /dev/null
+++ b/plugins/rainlab/translate/updates/create_locales_table.php
@@ -0,0 +1,27 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('code')->index();
+ $table->string('name')->index()->nullable();
+ $table->boolean('is_default')->default(0);
+ $table->boolean('is_enabled')->default(0);
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_translate_locales');
+ }
+
+}
diff --git a/plugins/rainlab/translate/updates/create_messages_table.php b/plugins/rainlab/translate/updates/create_messages_table.php
new file mode 100644
index 0000000..2532405
--- /dev/null
+++ b/plugins/rainlab/translate/updates/create_messages_table.php
@@ -0,0 +1,25 @@
+engine = 'InnoDB';
+ $table->increments('id');
+ $table->string('code')->index()->nullable();
+ $table->mediumText('message_data')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('rainlab_translate_messages');
+ }
+
+}
diff --git a/plugins/rainlab/translate/updates/migrate_morphed_attributes.php b/plugins/rainlab/translate/updates/migrate_morphed_attributes.php
new file mode 100644
index 0000000..10b9b07
--- /dev/null
+++ b/plugins/rainlab/translate/updates/migrate_morphed_attributes.php
@@ -0,0 +1,32 @@
+getTable();
+ foreach (Relation::$morphMap as $alias => $class) {
+ Db::table($table)->where('model_type', $class)->update(['model_type' => $alias]);
+ }
+ }
+
+ public function down()
+ {
+ $table = (new Attribute())->getTable();
+ foreach (Relation::$morphMap as $alias => $class) {
+ Db::table($table)->where('model_type', $alias)->update(['model_type' => $class]);
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/updates/migrate_morphed_indexes.php b/plugins/rainlab/translate/updates/migrate_morphed_indexes.php
new file mode 100644
index 0000000..29300f3
--- /dev/null
+++ b/plugins/rainlab/translate/updates/migrate_morphed_indexes.php
@@ -0,0 +1,32 @@
+ $class) {
+ Db::table($this->table)->where('model_type', $class)->update(['model_type' => $alias]);
+ }
+ }
+
+ public function down()
+ {
+ foreach (Relation::$morphMap as $alias => $class) {
+ Db::table($this->table)->where('model_type', $alias)->update(['model_type' => $class]);
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/updates/seed_all_tables.php b/plugins/rainlab/translate/updates/seed_all_tables.php
new file mode 100644
index 0000000..3157681
--- /dev/null
+++ b/plugins/rainlab/translate/updates/seed_all_tables.php
@@ -0,0 +1,21 @@
+ 'en',
+ 'name' => 'English',
+ 'is_default' => true,
+ 'is_enabled' => true
+ ]);
+ }
+ }
+
+}
diff --git a/plugins/rainlab/translate/updates/update_messages_table.php b/plugins/rainlab/translate/updates/update_messages_table.php
new file mode 100644
index 0000000..5d9955b
--- /dev/null
+++ b/plugins/rainlab/translate/updates/update_messages_table.php
@@ -0,0 +1,37 @@
+boolean('found')->default(1);
+ });
+ }
+ }
+
+ public function down()
+ {
+ if (!Schema::hasTable(self::TABLE_NAME)) {
+ return;
+ }
+
+ if (Schema::hasColumn(self::TABLE_NAME, 'found')) {
+ Schema::table(self::TABLE_NAME, function($table)
+ {
+ $table->dropColumn(['found']);
+ });
+ }
+ }
+}
diff --git a/plugins/rainlab/translate/updates/version.yaml b/plugins/rainlab/translate/updates/version.yaml
new file mode 100644
index 0000000..a731208
--- /dev/null
+++ b/plugins/rainlab/translate/updates/version.yaml
@@ -0,0 +1,99 @@
+v1.0.1:
+ - First version of Translate
+ - create_messages_table.php
+ - create_attributes_table.php
+ - create_locales_table.php
+v1.0.2: Languages and Messages can now be deleted.
+v1.0.3: Minor updates for latest October release.
+v1.0.4: Locale cache will clear when updating a language.
+v1.0.5: Add Spanish language and fix plugin config.
+v1.0.6: Minor improvements to the code.
+v1.0.7: Fixes major bug where translations are skipped entirely!
+v1.0.8: Minor bug fixes.
+v1.0.9: Fixes an issue where newly created models lose their translated values.
+v1.0.10: Minor fix for latest build.
+v1.0.11: Fix multilingual rich editor when used in stretch mode.
+v1.1.0: Introduce compatibility with RainLab.Pages plugin.
+v1.1.1: Minor UI fix to the language picker.
+v1.1.2: Add support for translating Static Content files.
+v1.1.3: Improved support for the multilingual rich editor.
+v1.1.4: Adds new multilingual markdown editor.
+v1.1.5: Minor update to the multilingual control API.
+v1.1.6: Minor improvements in the message editor.
+v1.1.7: Fixes bug not showing content when first loading multilingual textarea controls.
+v1.2.0: CMS pages now support translating the URL.
+v1.2.1: Minor update in the rich editor and code editor language control position.
+v1.2.2: Static Pages now support translating the URL.
+v1.2.3: Fixes Rich Editor when inserting a page link.
+v1.2.4:
+ - Translatable attributes can now be declared as indexes.
+ - create_indexes_table.php
+v1.2.5: Adds new multilingual repeater form widget.
+v1.2.6: Fixes repeater usage with static pages plugin.
+v1.2.7: Fixes placeholder usage with static pages plugin.
+v1.2.8: Improvements to code for latest October build compatibility.
+v1.2.9: Fixes context for translated strings when used with Static Pages.
+v1.2.10: Minor UI fix to the multilingual repeater.
+v1.2.11: Fixes translation not working with partials loaded via AJAX.
+v1.2.12: Add support for translating the new grouped repeater feature.
+v1.3.0: Added search to the translate messages page.
+v1.3.1:
+ - Added reordering to languages
+ - builder_table_update_rainlab_translate_locales.php
+ - seed_all_tables.php
+v1.3.2: Improved compatibility with RainLab.Pages, added ability to scan Mail Messages for translatable variables.
+v1.3.3: Fix to the locale picker session handling in Build 420 onwards.
+v1.3.4: Add alternate hreflang elements and adds prefixDefaultLocale setting.
+v1.3.5: Fix MLRepeater bug when switching locales.
+v1.3.6: Fix Middleware to use the prefixDefaultLocale setting introduced in 1.3.4
+v1.3.7: Fix config reference in LocaleMiddleware
+v1.3.8: Keep query string when switching locales
+v1.4.0: Add importer and exporter for messages
+v1.4.1: Updated Hungarian translation. Added Arabic translation. Fixed issue where default texts are overwritten by import. Fixed issue where the language switcher for repeater fields would overlap with the first repeater row.
+v1.4.2: Add multilingual MediaFinder
+v1.4.3: "!!! Please update OctoberCMS to Build 444 before updating this plugin. Added ability to translate CMS Pages fields (e.g. title, description, meta-title, meta-description)"
+v1.4.4: Minor improvements to compatibility with Laravel framework.
+v1.4.5: Fixed issue when using the language switcher
+v1.5.0: Compatibility fix with Build 451
+v1.6.0: Make File Upload widget properties translatable. Merge Repeater core changes into MLRepeater widget. Add getter method to retrieve original translate data.
+v1.6.1: Add ability for models to provide translated computed data, add option to disable locale prefix routing
+v1.6.2: Implement localeUrl filter, add per-locale theme configuration support
+v1.6.3: Add eager loading for translations, restore support for accessors & mutators
+v1.6.4: Fixes PHP 7.4 compatibility
+v1.6.5: Fixes compatibility issue when other plugins use a custom model morph map
+v1.6.6:
+ - Introduce migration to patch existing translations using morph map
+ - migrate_morphed_attributes.php
+v1.6.7:
+ - Introduce migration to patch existing indexes using morph map
+ - migrate_morphed_indexes.php
+v1.6.8: Add support for transOrderBy; Add translation support for ThemeData; Update russian localization.
+v1.6.9: Clear Static Page menu cache after saving the model; CSS fix for Text/Textarea input fields language selector.
+v1.6.10:
+ - Add option to purge deleted messages when scanning messages, Add Scan error column on Messages page, Fix translations that were lost when clicking locale twice while holding ctrl key, Fix error with nested fields default locale value, Escape Message translate params value.
+ - update_messages_table.php
+v1.7.0: "!!! Breaking change for the Message::trans() method (params are now escaped), fix message translation documentation, fix string translation key for scan errors column header."
+v1.7.1: Fix YAML issue with previous tag/release.
+v1.7.2: Fix regex when "|_" filter is followed by another filter, Try locale without country before returning default translation, Allow exporting default locale, Fire 'rainlab.translate.themeScanner.afterScan' event in the theme scanner for extendability.
+v1.7.3: Make plugin ready for Laravel 6 update, Add support for translating RainLab.Pages MenuItem properties (requires RainLab.Pages v1.3.6), Restore multilingual button position for textarea, Fix translatableAttributes.
+v1.7.4: Faster version of transWhere, Mail templates/views can now be localized, Fix messages table layout on mobile, Fix scopeTransOrderBy duplicates, Polish localization updates, Turkish localization updates, Add Greek language localization.
+v1.8.0: Adds initial support for October v2.0
+v1.8.1: Minor bugfix
+v1.8.2: Fixes translated file models and theme data for v2.0. The parent model must implement translatable behavior for their related file models to be translated.
+v1.8.4: Fixes the multilingual mediafinder to work with the media module.
+v1.8.6: Fixes invisible checkboxes when scanning for messages.
+v1.8.7: Fixes Markdown editor translation.
+v1.8.8: Fixes Laravel compatibility in custom Repeater.
+v1.9.0: Restores ability to translate URLs with CMS Editor in October v2.0
+v1.9.1: Minor styling improvements
+v1.9.2: Fixes issue creating new content in CMS Editor
+v1.9.3: Improves support when using child themes
+v1.10.0: Adds new multilingual nested form widget. Adds withFallbackLocale method.
+v1.10.1: Improve support with October v2.0
+v1.10.2: Improve support with October v2.2
+v1.10.3: Multilingual control improvements
+v1.10.4: Improve media finder support with October v2.2
+v1.10.5: Fixes media finder when only 1 locale is available
+v1.11.0: Update to latest Media Finder changes in October v2.2
+v1.11.1: Improve support with October v3.0
+v1.12.0: Adds scopeTransWhereNoFallback method
diff --git a/storage/cms/project.json b/storage/cms/project.json
deleted file mode 100644
index c0a0cd8..0000000
--- a/storage/cms/project.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "project": "1AQNlBQVgH1cOBQu8JRgPHRE8AScQJRE8ZRkQGRfgBTZlBTVmZQWwZGEyAzR1AzHkMJRlAJRmZmt0BQAxAwD"
-}
\ No newline at end of file