a-blog cmsで現在のエントリーを「関連エントリー」に登録しているエントリーを表示する

これは「a-blog cms Advent Calendar 2021」の、17日目の記事です。

https://adventar.org/calendars/6371

## 前置き

管理しているサイトで、数年前に「今表示しているエントリーが**関連付けられている**エントリーを表示できませんか」と相談されました。言い換えると現在のエントリーを**関連エントリーに登録している**エントリー、ということになります。

そのサイトでは、ブログを横断した関連エントリーを多用していました。ここでは*A→B*とします。
数十件くらい投稿してから、*B→A*の関連付けも追加して相互リンクにしようとしたところ、a-blog cmsの仕様上、エントリーBの編集画面へ行っても「関連付けをしているのがエントリーAである」ことがわからないので、AとBの編集画面を両方表示して作業している、とのことでした。

これは非常に難しいです。a-blog cmsのタグだけでは「関連付けられているエントリー」を調べる方法がないからです。相談された方は「これができたら助かるなー」レベルのご相談だったので、まとまった作業になるとお返事して、そのときは終了となりました。

## WsEntryRelatedモジュールの仕様

![スクリーンショット 2021-12-15 18.21.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21117/5322c99a-07e7-5849-0581-ed7e9dc3f6d5.png)

今年のAdvent Calendarのテーマは拡張アプリにしよう、ということで、当時の課題を実現しました。
「WsEntryRelated」アプリの仕様は、以下のとおりです。

* 現在のエントリーを「関連エントリー」に登録しているエントリーを検索し表示する
* すべてのブログを対象とする
* 日付、主カテゴリー、タイトルを公開日降順で表示する
* エントリー、カテゴリー、ブログが非公開、下書き、予約投稿になっている場合は、ログイン中かつ編集者以上のときのみ表示する

独自のSQL文を書きます。処理の流れは以下のとおりです。

* `relationship`テーブルの`relation_eid`に現在のエントリーIDが入っている行をすべて取得する
* `entry`テーブルの`entry_id`にそのID群が入っている行を再度取得する

SQL文を複雑にすれば一回のリクエストでできた気がしますが、今のところ試作なので二段階に分けます。

## 参考記事

a-blog cmsは基本的にPHPを書きませんが、拡張アプリ作成のリファレンスは一通りあります。

[開発 \| ドキュメント \| a\-blog cms developer](https://developer.a-blogcms.jp/document/develop/)

拡張アプリを実際に開発している、ジェノベーゼさんの記事も参考になると思います。

[a\-blog cmsの拡張アプリ開発時によく使う記述 \| a\-blog cms \| MR\. GRADATION](https://gradation.me/technology/a-blog-cms/extended-app-reference.html)

## ファイル構造

拡張アプリの中身は定義とGET関連の処理だけです。WsEntryRelatedディレクトリはa-blog cmsの `/extension/plugins/` 以下に置きます。

```
WsEntryRelated
├ ServiceProvider.php
└ GET
   └　WsEntryRelated.php
```

## アプリの内容

実際のコードを公開します。細かい解説は後ほど。

### ServiceProvider.php

DBテーブルを作らないので、定義ファイルには最低限の処理しか書いていません。

```ServiceProvider.php
<?php

namespace Acms\Plugins\WsEntryRelated;

use ACMS_App;
use Acms\Services\Common\HookFactory;
use Acms\Services\Common\CorrectorFactory;
use Acms\Services\Common\ValidatorFactory;
use Acms\Services\Common\InjectTemplate;

class ServiceProvider extends ACMS_App
{
    /**
     * @var string
     */
    public $version = '1.0.0';

    /**
     * @var string
     */
    public $name = 'WsEntryRelated';

    /**
     * @var string
     */
    public $author = 'webbingstudio';

    /**
     * @var bool
     */
    public $module = false;

    /**
     * @var bool|string
     */
    public $menu = false;

    /**
     * @var string
     */
    public $desc = '現在のエントリーを関連付けているエントリーを表示します。';

    /**
     * サービスの初期処理
     */
    public function init()
    {

    }

    /**
     * インストールする前の環境チェック処理
     *
     * @return bool
     */
    public function checkRequirements()
    {
        return true;
    }

    /**
     * インストールするときの処理
     * データベーステーブルの初期化など
     *
     * @return void
     */
    public function install()
    {

    }

    /**
     * アンインストールするときの処理
     * データベーステーブルの始末など
     *
     * @return void
     */
    public function uninstall()
    {

    }

    /**
     * アップデートするときの処理
     *
     * @return bool
     */
    public function update()
    {
        return true;
    }

    /**
     * 有効化するときの処理
     *
     * @return bool
     */
    public function activate()
    {
        return true;
    }

    /**
     * 無効化するときの処理
     *
     * @return bool
     */
    public function deactivate()
    {
        return true;
    }
}
```

### GET/WsEntryRelated.php

アプリの本体です。ACMS_GETやDB関連を継承しています。ACMS_Entryを継承するともっと高度な処理ができるようになります。

```WsEntryRelated.php
<?php

namespace Acms\Plugins\WsEntryRelated\GET;

use ACMS_GET;
use Template;
use DB;
use SQL;
use SQL_Select;
use ACMS_Corrector;

class WsEntryRelated extends ACMS_GET
{
    public $_scope = array(
        'bid' => 'global',
        'cid' => 'global',
        'eid' => 'global',
    );

    function get()
    {
        // 表示件数の上限
        $this->limit = 50;
        // 関連エントリータイプ
        $this->relation_type = 'default';

        $Tpl = new Template($this->tpl, new ACMS_Corrector());
        $DB = DB::singleton(dsn());

        $output = array();

        // 現在のエントリーを関連付けているエントリーのIDを配列で取得
        $SQL = SQL::newSelect('relationship');
        $SQL->addSelect('*');
        $SQL->addWhereOpr('relation_eid', $this->eid);
        $SQL->addWhereOpr('relation_type', $this->relation_type);
        $SQL->addOrder('relation_eid', 'ASC');
        $SQL->setLimit($this->limit, 0);
        $q = $SQL->get(dsn());

        $this->related = $DB->query($q, 'all');
        $this->related_eids = array_column($this->related, 'relation_id');
        // var_dump($this->related);

        // 1件以上ならエントリー情報を取得
        if( $this->related_eids && is_array($this->related_eids) ) {
            $SQL = SQL::newSelect('entry');
            $SQL->addSelect('*');
            $SQL->addLeftJoin('blog', 'blog_id', 'entry_blog_id');
            $SQL->addLeftJoin('category', 'entry_category_id', 'category_id');
            $SQL->addWhereIn('entry_id', $this->related_eids);
            $SQL->addWhereOpr('entry_status', 'trash', '<>');
            $SQL->addWhereOpr('entry_indexing', 'on');
            $SQL->addOrder('entry_posted_datetime', 'DESC');
            $SQL->setLimit($this->limit, 0);
            $q = $SQL->get(dsn());
    
            $this->entries = $DB->query($q, 'all');
            // var_dump($this->entries);

            foreach( $this->entries as $row ) {
                $show = false;
                if(
                    ($row['blog_status'] == '' || $row['blog_status'] == 'open')
                    && ($row['category_status'] == '' || $row['category_status'] == 'open')
                    && ($row['entry_status'] == '' || $row['entry_status'] == 'open')
                    && (strToTime($row['entry_start_datetime']) <= REQUEST_TIME )
                ) {
                    $show = true;
                } else if( sessionWithCompilation($this->bid) ) {
                    $show = true;
                }
                if( $show ) {
                    $output[] = array(
                        'eid' => $row['entry_id'],
                        'title' => addPrefixEntryTitle(
                                    $row['entry_title']
                                    , $row['entry_status']
                                    , $row['entry_start_datetime']
                                    , $row['entry_end_datetime']
                                    , $row['entry_approval']
                                ),
                        'date' => $row['entry_datetime'],
                        'cid' => $row['category_id'],
                        'categoryCode' => $row['category_code'],
                        'categoryName' => $row['category_name'],
                        'url' => acmsLink(array(
                            'eid'   => $row['entry_id'],
                            'cid'   => $row['category_id']
                        ))
                    );
                }
            }
        }

        $obj = array(
            'entry' => $output,
            'count' => count($output),
        );

        return $Tpl->render($obj);
    }

}
```

### 表示部分のテンプレートタグ

上のアプリを管理ページ「拡張アプリ」からインストールして、テーマの表示したい箇所に以下のテンプレートタグを書きます。

変数名はa-blog cmsのエントリー関連モジュールと同じですが、対象が存在しているかを判定する `entry:veil` `category:veil` オプションを有効にする方法がわからなかったので、IFブロックで代用しています。

```WsEntryRelated.html
<!-- BEGIN_MODULE WsEntryRelated -->
<!-- BEGIN_IF [{count}/gte/1] -->
<section class="module-section">
	<div class="module-header clearfix">
		<h2 class="module-heading">「%{ENTRY_TITLE}」を関連付けているエントリー</h2>
	</div>
	<ul class="headline acms-list-group clearfix"><!-- BEGIN entry:loop -->
		<li class="headline-item">
			<a href="{url}" class="acms-list-group-item headline-link">
				<time class="headline-dat" datetime="{date}[date('c')]">{date}[date('Y年m月d日')]</time>
				<!-- BEGIN_IF [{cid}/nem] --><span class="acms-label">{categoryName}</span><!-- END_IF -->
				<span class="headline-title">{title}</span>
			</a>
		</li><!-- END entry:loop -->
	</ul>
</section>
<!-- END_IF -->
<!-- END_MODULE WsEntryRelated -->
```

## アプリの解説

### 関連付けられを調べる

基本的な話ですが、生のSQL文を書いてはいけません。a-blog cmsのSQLクラスを利用します。`$SQL->add***`で条件を追加していき、`$SQL->get(dsn())`を実行すると、連想配列で結果が返ってきます。`var_dump`をコメントアウトしてあるので、結果を出力してチェックしてください。

データベース関連のクラスの解説はこちら。

[データベース関係クラスの使用\(2\) SQL Select編 \| 開発 \| ドキュメント \| a\-blog cms developer](https://developer.a-blogcms.jp/document/develop/db2-sql-select.html)

[SQL_Select　| a\-blog cms PHP](https://developer.a-blogcms.jp/document/reference/php/classes/SQL_Select.html)

現在表示しているページのエントリーIDは`$this->eid`で取れます。
**a-blog cmsの関連エントリーは複数のグループを作れる**ので、グループ名(relation_type)で絞り込む必要があります。ここでは固定値として、最初からある「default」を`$this->relation_type`に入れています。

```php
// 現在のエントリーを関連付けているエントリーのIDを配列で取得
$SQL = SQL::newSelect('relationship');
$SQL->addSelect('*');
$SQL->addWhereOpr('relation_eid', $this->eid);
$SQL->addWhereOpr('relation_type', $this->relation_type);
$SQL->addOrder('relation_eid', 'ASC');
$SQL->setLimit($this->limit, 0);
$q = $SQL->get(dsn());
```

### IDの配列からエントリーを調べる

続いて結果から再度、エントリーを返すSQL文を作成します。`$SQL->addLeftJoin`でブログとカテゴリーの情報も引っ張れます。あとで非公開判定に使用します。
`$SQL->addWhereIn`は配列を条件にすることができます。

「ゴミ箱に入っているエントリー」「一覧に表示しないエントリー（エントリーの編集画面でインデキシングがオフになっている）」を除外することに注意してください。

```php
$SQL = SQL::newSelect('entry');
$SQL->addSelect('*');
$SQL->addLeftJoin('blog', 'blog_id', 'entry_blog_id');
$SQL->addLeftJoin('category', 'entry_category_id', 'category_id');
$SQL->addWhereIn('entry_id', $this->related_eids);
$SQL->addWhereOpr('entry_status', 'trash', '<>');
$SQL->addWhereOpr('entry_indexing', 'on');
$SQL->addOrder('entry_posted_datetime', 'DESC');
$SQL->setLimit($this->limit, 0);
$q = $SQL->get(dsn());
```

### 非公開判定

管理目的で使用する場合は、非公開判定を実装しなければなりません。

* 日付、主カテゴリー、タイトルを公開日降順で表示する
* エントリー、カテゴリー、ブログが非公開、下書き、予約投稿になっている場合は、ログイン中かつ編集者以上のときのみ表示する

このアプリでは一つひとつ判定していく方向でif文を書きましたが、もしかしたらACMS_Entryクラスに関数があるかもしれないです。
`$show`がtrueならそのエントリーを表示します。

```php
$show = false;
if(
    ($row['blog_status'] == '' || $row['blog_status'] == 'open')
    && ($row['category_status'] == '' || $row['category_status'] == 'open')
    && ($row['entry_status'] == '' || $row['entry_status'] == 'open')
    && (strToTime($row['entry_start_datetime']) <= REQUEST_TIME )
) {
    $show = true;
} else if( sessionWithCompilation($this->bid) ) {
    $show = true;
}
```

### テンプレートのレンダリング

取得したエントリーからテンプレート変数用の連想配列を組み立て、`$Tpl->render` を通してreturnすればアプリの処理は完了です。配列名はWordPressぽく`$output`としていますが何でもいいです。

```php
$output[] = array(
...
);

$obj = array(
    'entry' => $output,
    'count' => count($output),
);

return $Tpl->render($obj);
```

キーが変数名になりますから、ここでは以下の変数が使えることになります。a-blog cmsのビルトインモジュールと同じように、自動でHTMLタグがエスケープされ、校正オプションも使えます。

`{eid},{title},{date},{cid},{categoryCode},{categoryName},{url}`

ログイン中、制限されたエントリーのタイトルには【非公開】や【公開予約】が付きます。この処理は`addPrefixEntryTitle`関数でできます。

```php
'title' => addPrefixEntryTitle(
               $row['entry_title']
               , $row['entry_status']
               , $row['entry_start_datetime']
               , $row['entry_end_datetime']
               , $row['entry_approval']
           ),
```

また、パーマリンクの生成も`acmsLink`関数でできます。a-blog cmsのパーマリンクはカテゴリーに依存するのでカテゴリーIDも渡します。

```php
'url' => acmsLink(array(
             'eid'   => $row['entry_id'],
             'cid'   => $row['category_id']
         ))
```

## 実用にはまだまだ…

アプリの解説は以上です。
リンクを表示するだけでしたら充分使えますので、ご自由に使用・改良してください。ブログの構成によっては想定外の挙動があるかもしれません。必ず利用予定の環境で動作テストをしてください。

そんなこんなで要件の達成はできましたが、実際のサイトでがっつり使うための機能は不足しています。正式な拡張アプリにするなら、以下も実装しなければならないでしょう。

* メイン画像とメディア管理
* カスタムフィールド
* 使用する関連エントリーグループ名を管理ページで選択、もしくはモジュールIDで渡す
* date#Y などのフォーマット変数
* 関連エントリーで既に表示されていた場合は除外する
* キャッシュの考慮（これは大丈夫かも）

拡張が必要になるのはだいたいエントリーの処理関係なので、ACMS_Entryの関数の解説資料がもう少しあればいいなあ♪と無茶を言って今年のAdvent Calendarの記事を終わります。