Статьи и новости

Контент на нескольких языках в Yii2. Пишем словарь. Часть 2

Часть 1
Часть 2
Часть 3

В прошлой части мы написали систему по добавлению частей речи на сайт через админку. Самое время приступить к реализации основной задачи. Напомню,

Нужен словарь, куда администратор сайта будет добавлять, собственно, слова, а также их словоформы. Затем мы будем вытаскивать по нужной словоформе слово в базовой форме и давать его перевод, а также позволим пользователям добавлять его в свой собственный словарь для дальнейшего изучения.

Немного уточню: наш сервис подразумевает, что учиться будут носители русского и английского языка (с возможностью дальнейшего расширения количества языков), а учить они будут третий. Неважно, филиппинский язык, креольский или греческий.

Приступим к реализации словаря.

Набросаем миграции

Учитываем, что переводиться на несколько языков будут только поля comment и translate.

public function safeUp()
{
    $tableOptions = null;
    if ($this->db->driverName === 'mysql') {
        $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
    }

    $this->createTable('dictionary', [
        'id' => $this->primaryKey(),
        'word' => $this->string(64), // слово, для которого будем делать переводы
        'parent_id' => $this->integer(5), // если есть родитель, значит, что текущее слово - словоформа
        'part_of_speech' => $this->integer(5), // к какой части речи относится данное слово. Связь с таблицей parts_of_speech
    ], $tableOptions);

    $this->createTable('dictionary_translate', [
        'id' => $this->primaryKey(),
        'word_id' => $this->integer(), // для какого слова перевод? Связь с таблицей dictionary_translate
        'language' => $this->string(), // тут будет лежать код языка перевода
        'translate' => $this->string(), // перевод слова
         'comment' => $this->string(), // если нужно объяснение значения слова, оно будет в этой строке

    ], $tableOptions);

    // Создадим индексы нужных полей
    $this->createIndex(
        'idx-dictionary_translate-word_id',
        'dictionary_translate',
        'word_id'
    );

    $this->createIndex(
        'idx-dictionary_translate-language',
        'dictionary_translate',
        'language'
    );

    /* Cоздадим внешний ключ для связи с таблицей parts_of_speech */
    // См. документацию https://www.yiiframework.com/doc/guide/2.0/en/db-migrations, блок "add foreign key for table `user`"
    $this->addForeignKey(
        'fk-dictionary-part_of_speech',
        'dictionary', // какую таблицу связываем
        'part_of_speech', // по какому полю
        'parts_of_speech', // с какой таблицей
        'id', // по какому её полю
        'CASCADE' // тип
    );

    /* Аналогично, создадим внешний ключ в таблице переводов, связав её с основной таблицей словаря */
    $this->addForeignKey(
        'fk-dictionary_translate-word_id',
        'dictionary_translate', // какую таблицу связываем
        'word_id', // по какому полю
        'dictionary', // с какой таблицей
        'id', // по какому её полю
        'CASCADE' // тип
    );
}

Забавная штука: ключ translation оказался зарезервированным, поэтому пришлось поле переименовать в translate.

В случае некорректной миграции будем удалять также обе созданные таблицы и внешние ключи. Индексы дропать не будем, ну их.

public function safeDown()
{
    //Добавляем удаление внешних ключей
     $this->dropForeignKey(
         'fk-dictionary_translate-word_id', // имя ключа
         'dictionary_translate' // к какой таблице он был добавлен
     );
         
     $this->dropForeignKey(
        'fk-dictionary-part_of_speech', // имя ключа
        'dictionary' // к какой таблице он был добавлен
     );

    $this->dropTable('dictionary');
    $this->dropTable('dictionary_translate');
}

Модель и CRUD

Создадим модель по тем же принципам, что и в прошлом посте.

Настроим внутри неё мультиязычное поведение и правила валидации:

public function behaviors()
{
    return [
        'ml' => [
            'class' => MultilingualBehavior::className(),
            'languages' => [
                'ru' => 'Russian',
                'en-US' => 'English',
            ],
            'defaultLanguage' => 'ru',
            'tableName' => "{{%dictionary_translate}}",
            'langForeignKey' => 'word_id',
            'attributes' => [
                'comment', 'translation', // Передаем массив переводимых полей
            ]
        ],
    ];
}

public function rules()
{
    return [
        [['parent_id', 'part_of_speech'], 'integer'],
        [['word'], 'string', 'max' => 64],
        [['part_of_speech'], 'exist', 'skipOnError' => true, 'targetClass' => PartsOfSpeech::className(), 'targetAttribute' => ['part_of_speech' => 'id']],
        [['comment', 'translation'], 'string']
    ];
}

Также изменим метод find() и добавим use.

Аналогично прошлому уроку, сгенерируем крудЪ и пропишем в контроллере правильный метод findModel($id).

Откорректируем форму в виде для заполнения нужных полей (и удалим лишнее поле parent_id, его в дальнейшем мы будем заполнять автоматически).

<?php $form = ActiveForm::begin(); ?>

    <?= $form->field($model, 'word')->textInput(['maxlength' => true]) ?>

    <?= $form->field($model, 'part_of_speech')->textInput() ?>

    <div class="row">
        <div class="col-sm-6">

            <h3>Перевод на русский язык</h3>

            <?= $form->field($model, 'translation') ?>

            <?= $form->field($model, 'comment') ?>

        </div>
        <div class="col-sm-6">

            <h3>English version</h3>

            <?= $form->field($model, 'translation_en') ?>

            <?= $form->field($model, 'comment_en') ?>

        </div>
    </div>

    <div class="form-group">
        <?= Html::submitButton('Сохранить', ['class' => 'btn btn-success']) ?>
    </div>

    <?php ActiveForm::end(); ?>

Также заполним лейблы для полей и получим такую форму:

Самое время воспользоваться написанным в прошлой части кодом и вывести выпадающим списком возможные варианты частей речи. Для этого подключим модель частей речи в наш контроллер, а также поключим ArrayHelper для формирования правильного списка:

#backend\controllers\DictionaryController
use common\models\PartsOfSpeech;
use yii\helpers\ArrayHelper;

И напишем метод контроллера, который будет возвращать обработанный массив, состоящий из всех частей речи, добавленных через админку.

public function getAllPartsOfSpeech(){
    $parts = PartsOfSpeech::find()->asArray()->all();
    $array = ArrayHelper::map($parts, 'id', 'name');
    return $array;
}

Передадим части речи в вид внутри экшнов (на примере экшна create):

public function actionCreate()
{
    $model = new Dictionary();

    if ($model->load(Yii::$app->request->post()) && $model->save()) {
        return $this->redirect(['view', 'id' => $model->id]);
    }

    return $this->render('create', [
        'model' => $model,
        'parts_of_speech' => $this->getAllPartsOfSpeech()
    ]);
}

Не забудьте передать эти данные в update, а также прокинуть их через вид в форму.

И, наконец, в виде делаем вот такую штуку:

# /backend/views/dictionary/_form.php
$params = ['selection' => $model->part_of_speech ];
echo $form->field($model, 'part_of_speech')->dropDownList($parts_of_speech, $params);

И вот у нас готовая и работающая форма добавления нового слова в словарь.

Словоформы

Самое время реализовать возможность добавления словоформы к родительскому слову через тот же CRUD. По сути, механизм будет все тот же, за исключением передачи параметра с ID родителя.

Для начала, добавим кнопку добавления потомка в GridView:

# /backend/views/dictionary/index.php

<?= GridView::widget([
        'dataProvider' => $dataProvider,
        'columns' => [
            ['class' => 'yii\grid\SerialColumn'],

            'id',
            'word',
            'parent_id',
            'part_of_speech'

            [
                'class' => \yii\grid\ActionColumn::class,

                /* Кусок кода с добавленной кнопкной */
                'template' => '{view} {add} {update} {delete} ',

                'buttons' => [
                    'add' => function ($url, $model, $key) {
                        $iconName = "duplicate";
                        $title = 'Добавить словоформу';
                        $id = 'add-'.$key;
                        $options = [
                            'title' => $title,
                            'id' => $id
                        ];

                        $url = Url::current(['dictionary/add', 'id' => $key]);
                        $icon = Html::tag('span', '', ['class' => "glyphicon glyphicon-$iconName"]);
                        return Html::a($icon, $url, $options);
                    },
                ],
                /* Конец куска */
            ],
        ],
    ]); ?>

Подробнее о том, как и почему это сделано, смотрите тут.

Теперь, когда у нас есть ссылка на экшн, опишем его:

# /backend/controllers/DictionaryController.php

public function actionAdd($id)
{
    $model = new Dictionary();

    if($this->parentExist($id)){
        $model->parent_id = $id;
    } else {
        throw new NotFoundHttpException('Родительская форма слова не найдена или Вы пытаетесь добавить словоформу другой словоформе.');
    }

    if ($model->load(Yii::$app->request->post()) && $model->save()) {
        return $this->redirect(['view', 'id' => $model->id]);
    }

    return $this->render('create', [
        'model' => $model,
        'parts_of_speech' => $this->getAllPartsOfSpeech()
    ]);
}

Фактически, это немного усовершенствованный код из стандартного экшна create, лишь с тем различием, что в нем мы проверяем, действительно ли в нашей базе существует запись с переданным id, и если это так, делаем присвоение модели:

$model->parent_id = $id;

Помимо этого, нужно написать в нашем контроллере метод для проверки существования родителя:

public function parentExist($id){
    // Если запись существует
    if (($model = Dictionary::find()->where(['id' => $id])->multilingual()->one()) !== null) {
        // и у неё нет родителя
        if( $model->parent_id == null ){
            return true;
        }
        return false;
    }
    return false;
}

Что важно: для избежания круговых ссылок слов друг на друга мы запрещаем ссылаться на словоформы как на родителей. Благо, сам язык (под изучение которого разрабатывается сайт) нам это позволяет. Ну и в контроллере бросаем 404 исключение, если родителя с переданным id не существует или он сам является потомком.

Теоретически, можно написать рекурсивную функцию, которая будет определять родителя первого уровня и проставлять его id-шник, но в данном случае реализация не требует такого подхода, да и сегодня мне уже лень это делать.

Вывод части речи вместо её ID

В заключительном на сегодня блоке выведем часть речи строкой вместо её идентификатора.

В виджете

# /backend/views/dictionary/view.php

<?= DetailView::widget([
    'model' => $model,
    'attributes' => [
        'id',
        'word',
        'parent_id',
        /* Выведем поле с нужным лейблом и значением */
        [
            'label' => 'Часть речи',
            'value' => $model->getPartOfSpeech($model->part_of_speech),
        ],
    ],
]) ?>

Также нужно написать функцию внутри модели, которая будет возвращать строкой значение по переданному id.

# /common/models/Dictionary.php

public function getPartOfSpeech($id)
{
    $part_of_speech = PartsOfSpeech::find()->where(['id' => $id])->asArray()->one();
    return $part_of_speech['name'];
}

Как мне кажется, получилось более чем наглядно.

И делаем аналогичную весчь в GridView общего списка.

        [
            'attribute' => 'part_of_speech',
            'label' => 'Часть речи',
            'format' => 'text',
            'content' => function($data){
                return $data->getPartOfSpeech($data->part_of_speech);
            }
        ],

Время прерваться на сон, чем я, пожалуй, и займусь.

Thanks to:

  1. Про удаление таблиц с внешними ключами
  2. Официальная документация по работе с миграциями в Yii2
  3. Создаем выпадающий список со встроенным хелпером Yii2
  4. [!важно при работе с ActiveRecord] Параметры dropDownList
  5. Как создать свою кнопку в GridView
  6. Проверка на существование записи

Ваш комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *