顯示具有 php 標籤的文章。 顯示所有文章
顯示具有 php 標籤的文章。 顯示所有文章

2024年12月5日 星期四

PHP 開發筆記 - 產生 CountryCode, CountryName, CityName, GeoLocation 列表

原本一直盧 AI 產出,發現產出的品質很難控,最後就轉個彎去把 GeoIP DB 內的資料輸出即可,而策略也很簡單,單純用 IP 暴力輪詢。理論上可以優雅一點去了解 DB Record format,總之,暴力解也很快,就順手先記錄一下,此例僅列出部分資訊(非輪詢所有 IPv4):

```
<?php
if (!extension_loaded('geoip')) {
    die("GeoIP extension is not installed\n");
}

class GeoIPParser {
    private $dbPath;
    
    public function __construct($dbPath = '/tmp/db.dat') {
        $this->dbPath = $dbPath;
        geoip_setup_custom_directory('/tmp');
    }
    
    public function parse() {
        if (!file_exists($this->dbPath)) {
            throw new Exception("GeoIP City database not found at {$this->dbPath}");
        }
        
        $locations = [];
        $processed = [];
        
        try {
            // 遍歷 IP 範圍,每個 /16 subnet 取樣幾個 IP
            for ($first = 1; $first <= 255; $first++) {
                fprintf(STDERR, "\rProcessing IP block: %d/255", $first);
                
                for ($second = 0; $second <= 255; $second += 5) {
                    $ip = "$first.$second.1.1";
                    $record = geoip_record_by_name($ip);
                    
                    if ($record && 
                        !empty($record['country_code']) && 
                        !empty($record['city'])) {
                        
                        $key = $record['country_code'] . '|' . $record['city'];
                        
                        if (!isset($processed[$key])) {
                            $location = [
                                'country_code' => $record['country_code'],
                                'country_name' => $record['country_name'],
                                'city_name' => $record['city'],
                                'geo_location' => [
                                    'latitude' => round($record['latitude'], 4),
                                    'longitude' => round($record['longitude'], 4)
                                ]
                            ];
                            
                            $locations[] = $location;
                            $processed[$key] = true;
                        }
                    }
                }
            }
            
            fprintf(STDERR, "\nProcessing completed. Total locations found: " . count($locations) . "\n");
            
            // 按國家代碼和城市名稱排序
            usort($locations, function($a, $b) {
                $countryComp = strcmp($a['country_code'], $b['country_code']);
                return $countryComp === 0 ? 
                    strcmp($a['city_name'], $b['city_name']) : 
                    $countryComp;
            });
            
            return $locations;
        } catch (Exception $e) {
            fprintf(STDERR, "Error during processing: " . $e->getMessage() . "\n");
            throw $e;
        }
    }
}

try {
    $parser = new GeoIPParser();
    $locations = $parser->parse();
    
    // 輸出為格式化的 JSON
    echo json_encode($locations, 
        JSON_PRETTY_PRINT | 
        JSON_UNESCAPED_UNICODE | 
        JSON_UNESCAPED_SLASHES
    );
    
} catch (Exception $e) {
    fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
    exit(1);
}
```

產出:

```
% php83 -ini | grep geoip
/opt/local/var/db/php83/geoip.ini,
geoip
geoip support => enabled
geoip extension version => 1.1.1
geoip library version => 1005000
geoip.custom_directory => no value => no value
% php83 geo-lookup.php 
Processing IP block: 255/255
Processing completed. Total locations found: 2657
...
```

片段資料: 

% php83 list-geo-city.php | jq '[.[]|select(.country_code=="TW")]'         
Processing IP block: 255/255
Processing completed. Total locations found: 8020
[
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Anping District",
    "geo_location": {
      "latitude": 22.9965,
      "longitude": 120.1617
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Bade District",
    "geo_location": {
      "latitude": 24.9259,
      "longitude": 121.2763
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Banqiao",
    "geo_location": {
      "latitude": 25.0104,
      "longitude": 121.4683
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Beitou",
    "geo_location": {
      "latitude": 25.1403,
      "longitude": 121.4948
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chang-hua",
    "geo_location": {
      "latitude": 24.0759,
      "longitude": 120.5657
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chiayi City",
    "geo_location": {
      "latitude": 23.4815,
      "longitude": 120.4498
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chiyayi County",
    "geo_location": {
      "latitude": 23.4461,
      "longitude": 120.5728
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Daan",
    "geo_location": {
      "latitude": 25.0316,
      "longitude": 121.5345
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Dacun",
    "geo_location": {
      "latitude": 23.9978,
      "longitude": 120.547
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Dawan",
    "geo_location": {
      "latitude": 23.2073,
      "longitude": 120.1906
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Daya",
    "geo_location": {
      "latitude": 24.2226,
      "longitude": 120.6493
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Douliu",
    "geo_location": {
      "latitude": 23.7125,
      "longitude": 120.545
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "East District",
    "geo_location": {
      "latitude": 22.9721,
      "longitude": 120.2224
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Guishan",
    "geo_location": {
      "latitude": 25.0273,
      "longitude": 121.359
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hsinchu",
    "geo_location": {
      "latitude": 24.8065,
      "longitude": 120.9706
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hsinchu County",
    "geo_location": {
      "latitude": 24.673,
      "longitude": 121.1614
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hualien City",
    "geo_location": {
      "latitude": 23.9807,
      "longitude": 121.6115
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Jian",
    "geo_location": {
      "latitude": 23.9516,
      "longitude": 121.5639
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Jinshan",
    "geo_location": {
      "latitude": 25.0613,
      "longitude": 121.5705
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Kaohsiung City",
    "geo_location": {
      "latitude": 22.6148,
      "longitude": 120.3139
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Keelung",
    "geo_location": {
      "latitude": 25.1322,
      "longitude": 121.742
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Linkou District",
    "geo_location": {
      "latitude": 25.0738,
      "longitude": 121.3935
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Miaoli",
    "geo_location": {
      "latitude": 24.5641,
      "longitude": 120.8275
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Nantou City",
    "geo_location": {
      "latitude": 23.9082,
      "longitude": 120.6558
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Neihu District",
    "geo_location": {
      "latitude": 25.0811,
      "longitude": 121.5838
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "New Taipei City",
    "geo_location": {
      "latitude": 24.9466,
      "longitude": 121.586
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Penghu County",
    "geo_location": {
      "latitude": 23.5748,
      "longitude": 119.6098
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Pingtung City",
    "geo_location": {
      "latitude": 22.6745,
      "longitude": 120.491
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Puli",
    "geo_location": {
      "latitude": 23.9678,
      "longitude": 120.9644
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Sanchong District",
    "geo_location": {
      "latitude": 25.0691,
      "longitude": 121.4878
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Sanxia District",
    "geo_location": {
      "latitude": 24.9336,
      "longitude": 121.372
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Shiding District",
    "geo_location": {
      "latitude": 24.9956,
      "longitude": 121.6546
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Shoufeng",
    "geo_location": {
      "latitude": 23.8341,
      "longitude": 121.521
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taibao",
    "geo_location": {
      "latitude": 23.4603,
      "longitude": 120.3284
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taichung",
    "geo_location": {
      "latitude": 24.144,
      "longitude": 120.6844
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taichung City",
    "geo_location": {
      "latitude": 24.1547,
      "longitude": 120.6716
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Tainan City",
    "geo_location": {
      "latitude": 22.9917,
      "longitude": 120.2147
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taipei",
    "geo_location": {
      "latitude": 25.0504,
      "longitude": 121.5324
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taitung",
    "geo_location": {
      "latitude": 22.7563,
      "longitude": 121.1418
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taoyuan",
    "geo_location": {
      "latitude": 24.9977,
      "longitude": 121.2965
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taoyuan District",
    "geo_location": {
      "latitude": 24.9889,
      "longitude": 121.3175
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Xizhi District",
    "geo_location": {
      "latitude": 25.0696,
      "longitude": 121.6577
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yilan",
    "geo_location": {
      "latitude": 24.7574,
      "longitude": 121.7421
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yongkang District",
    "geo_location": {
      "latitude": 23.0204,
      "longitude": 120.2591
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yunlin",
    "geo_location": {
      "latitude": 23.7113,
      "longitude": 120.3897
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zhongli District",
    "geo_location": {
      "latitude": 24.9614,
      "longitude": 121.2437
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zhubei",
    "geo_location": {
      "latitude": 24.8351,
      "longitude": 121.0056
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zuoying",
    "geo_location": {
      "latitude": 22.6868,
      "longitude": 120.2971
    }
  }
]

2024年8月6日 星期二

PHP 開發筆記 - 嘗試使用 Laravel + Twill CMS Toolkit 開發 CMS 後台管理服務 @ macOS M1



近期公司內部服務都朝向 Laravel framework 來維護,其中常見需求是製作具有 CMS 管理的機制,提供不同部門的同事編輯資料及發佈出去。就來試試看 Twill 這個 CMS Toolkit 套件。從他的文件得知,引入他可以快速擁有後台登入機制,以及省去自己規劃資料庫的資料表,而相較 wordpress 則是有更大的彈性做事。

目前在 Macbook M1 和 MacPorts 環境下,操作一下,先弄個 Laravel project 出來:

```
% sudo port install php83 php83-iconv php83-intl php83-mbstring php83-openssl php83-curl php83-sqlite php83-zip php83-gd php83-exif
% alias php=php83
% wget https://getcomposer.org/download/latest-stable/composer.phar -O /tmp/composer.phar
% php /tmp/composer.phar self-update
% alias composer="php /tmp/composer.phar"
% composer create-project --prefer-dist laravel/laravel /tmp/laravel-workspace
Creating a "laravel/laravel" project at "/tmp/laravel-workspace"
Installing laravel/laravel (v11.1.4)
  - Installing laravel/laravel (v11.1.4): Extracting archive
Created project in /tmp/laravel-workspace
> @php -r "file_exists('.env') || copy('.env.example', '.env');"
Loading composer repositories with package information
Updating dependencies
...
```
這邊偷懶用 alias composer="php /tmp/composer.phar" ,後面會碰到類似的的錯誤訊息時,其實就是找不到 composer 指令:

```
   Symfony\Component\Process\Exception\ProcessStartFailedException 

  The command "'composer' 'dump-autoload'" failed.

Working directory: /private/tmp/laravel-workspace

Error: proc_open(): posix_spawn() failed: No such file or directory
```

因此可以把 /tmp/composer.phar 擺到 PATH 內會尋找指令的地方,或是人工再補個 composer dump-autoload 等等

```
% cp /tmp/composer.phar ~/.bin/composer
% chmod 755 ~/.bin/composer
```

接下來安裝 Twill Toolkit,整個流程其實參考 Twill 官網教學文即可:Building a simple page builder with Laravel Blade,在此僅記錄一下操作流程:

```
% cd /tmp/laravel-workspace
laravel-workspace % composer require area17/twill:"^3.0"
laravel-workspace % php artisan twill:install
...
Let's create a superadmin account!

 Enter an email:
 > user@example.com

 Enter a password:
 > 

 Confirm the password:
 > 

Your account has been created
All good!
```

接著安裝後台模組 Pages:

```
laravel-workspace % php artisan twill:make:module pages

 Do you need to use the block editor on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to translate content on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to generate slugs on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to attach images on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to attach files on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to manage the position of records on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to enable revisions on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to enable nesting on this module? [no]:
  [0] no
  [1] yes
 > 

 Do you also want to generate a model factory? [yes]:
  [0] no
  [1] yes
 > 

 Do you also want to generate a model seeder? [yes]:
  [0] no
  [1] yes
 > 

Migration created successfully! Add some fields!

   INFO  Factory [database/factories/PageFactory.php] created successfully.  

Models created successfully! Fill your fillables!
Repository created successfully! Control all the things!
Controller created successfully! Define your index/browser/form endpoints options!
Form request created successfully! Add some validation rules!

 Do you also want to generate the preview file? [yes]:
  [0] no
  [1] yes
 > 

   INFO  Seeder [database/seeders/PageSeeder.php] created successfully.  

The following snippet has been added to routes/twill.php:
-----
TwillRoutes::module('pages');
-----
To add a navigation entry add the following to your AppServiceProvider BOOT method.
-----
use A17\Twill\Facades\TwillNavigation;
use A17\Twill\View\Components\Navigation\NavigationLink;

public function boot()
{
    ...
    
    TwillNavigation::addLink(
        NavigationLink::make()->forModule('pages')
    );
}
-----
Do not forget to migrate your database after modifying the migrations.

Enjoy.
```

上述的意思是 routes/twill.php 已經增加好後台管理介面的 routing 規則:

```
% cat routes/twill.php 
<?php

use A17\Twill\Facades\TwillRoutes;

// Register Twill routes here eg.
// TwillRoutes::module('posts');

TwillRoutes::module('pages');
```

但是 app/Providers/AppServiceProvider.php 必須自己處理,調整成下方:

```
laravel-workspace % cat app/Providers/AppServiceProvider.php 
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use A17\Twill\Facades\TwillNavigation;
use A17\Twill\View\Components\Navigation\NavigationLink;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        TwillNavigation::addLink(
            NavigationLink::make()->forModule('pages')
        );
    }
}
```

這些更動是讓後台 /admin 時,上方導覽可以多一項 Pages 的功能,後續透過下方來使用:

laravel-workspace % php artisan migrate
laravel-workspace % php artisan serve --host 0.0.0.0 --port 8000

如此,就可以用 http://localhost:8000/admin 登入後台,點擊 Pages 切換到 http://localhost:8000/admin/pages 可以新增 Pages ,可點擊 Add new 按鈕一則,這時可以看到前台網址規則是 localhost/en/pages/hello-world ,但實際上在 PHP Laravel routing 規則中,前台網址規則都還沒實作處理,所以是看不到資料的。


上述僅做了簡易的後台搭建,後續要處理的有:
  1. 讓後來編輯界面更加豐富,例如有更多的元件(image, text)等,主要是擴增編輯使用的表單元素
  2. 處理後台編輯時,preview 缺少的 css 資源 (運行 npm install && npm run build)
  3. 建立新元素的樣板資料
  4. 建立前台網頁的網址規則跟處理的 Controller (PageDisplayController)
如此,在後台把頁面發布出去後,就可以在前台被瀏覽到。但光上述四點的操作項目是不少的。

接著來進行,也就是 Twill 官網導覽流程,並且把其他碰到的問題也解一解:Configuring the page module

由於預設的前台網頁網址規則是跟語言相關的,例如建立個 Hello World Page 後,可以看到他的前台網址是 localhost/en/pages/hello-world ,若要去掉語言,就是調整 app/Http/Controllers/Twill/PageController.php :

```
 18     protected function setUpController(): void
 19     {
 20         $this->setPermalinkBase('');          // 去掉 /pages/ 那層網址
 21         $this->withoutLanguageInPermalink();  // 去掉 /en/ 那層網址 
 22     }
```

接著來調整撰文時的表單功能,例如目前每一個 Page 都可以填寫 title 跟 description 可增加 SEO 的效果,而增加文章分享後的美觀,則是要增加圖片,這時直接修改 app/Http/Controllers/Twill/PageController.php ,添加發文時可以上傳圖片功能:

```
  6 use A17\Twill\Services\Forms\Fields\Medias;
...
 29     public function getForm(TwillModelContract $model): Form
 30     {   
 31         $form = parent::getForm($model);
 32         
 33         $form->add(
 34             Input::make()->name('description')->label('Description')->translatable()
 35         );
 36         
 37         $form->add(
 38             Medias::make()->name('cover')->label('Cover image')
 39         );
 40         
 41         return $form;
 42     }
...
```

主要是新增引入 `use A17\Twill\Services\Forms\Fields\Medias;` 跟 `$form->add( Medias::make()->name('cover')->label('Cover image') );`


這時我們的 laravel 是透過 artisan 跑在 8000 port,拖拉上傳圖片時,其實會顯示不出來


因為他的圖片網址規則並沒有帶 port ,且只要把網址複製出來加上 port number 就可以正常顯示,代表只需處理 Laravel framework 的 .env 中 APP_URL 規則:

```
% cat .env | grep APP_URL
APP_URL=http://localhost
```

這時建議測試時可以有兩套環境設置,運行時指定設定檔案:

```
% cp .env .env.local
% cat .env.local | grep APP_URL 
APP_URL=http://localhost:8000
% php artisan serve --host 0.0.0.0 --port 8000 --env local

   INFO  Server running on [http://0.0.0.0:8000].  

  Press Ctrl+C to stop the server
```

如此也解掉後台圖片顯示失敗的問題。


下一刻則是擴充文章編輯環境,引入 Block Editor 架構,也就是在編輯文章時,可以拖拉區塊到文章內,還可以自行開發,把常用的項目元件化。從 Twill 官網的範例資訊,預設有兩個 Block 了,一個是 image block ,另一個是 wysiwyg block,官網範例會試著新增一個小的區塊,例如名為 Text 的區塊。

首先,先啟用 Block Editor,啟用方式是在 app/Http/Controllers/Twill/PageController.php 裡引入 `use A17\Twill\Services\Forms\Fields\BlockEditor;` 和增加 `$form->add( BlockEditor::make() );`

接著則是試著建立一個 text block:

```
laravel-workspace % php artisan twill:make:block text

 Should we also generate a view file for rendering the block? (yes/no) [no]:
 > yes

Creating block...
File: /private/tmp/laravel-workspace/resources/views/twill/blocks/text.blade.php
Block text was created.
Block text blank render view was created.
Block is ready to use with the name 'text'

laravel-workspace % cat resources/views/twill/blocks/text.blade.php
@twillBlockTitle('Text')
@twillBlockIcon('text')
@twillBlockGroup('app')

<x-twill::input
    name="title"
    label="Title"
    :translated="true"
/>

<x-twill::wysiwyg
    name="text"
    label="Text"
    placeholder="Text"
    :toolbar-options="[
        'bold',
        'italic',
        ['list' => 'bullet'],
        ['list' => 'ordered'],
        [ 'script' => 'super' ],
        [ 'script' => 'sub' ],
        'link',
        'clean'
    ]"
    :translated="true"
/>
```

可以看到其樣板也長出來了


但是在後台 preview 時,仍會有待處理的訊息:

This is a basic preview. You can use dd($block) to view the data you have access to. <br />This preview file is located at: /private/tmp/laravel-workspace/resources/views/site/blocks/text.blade.php

將他修改一下:

```
% cat resources/views/site/blocks/text.blade.php
<div class="prose">
    <h2>{{$block->translatedInput('title')}}</h2>
    {!! $block->translatedInput('text') !!}
</div>
```    


接著再產生另一個 image block 並更新他的 block view 跟 preview:

```
laravel-workspace % php artisan twill:make:block image

 Should we also generate a view file for rendering the block? (yes/no) [no]:
 > yes

Creating block...
File: /private/tmp/laravel-workspace/resources/views/twill/blocks/image.blade.php
Block image was created.
Block image blank render view was created.
Block is ready to use with the name 'image'

laravel-workspace % cat resources/views/twill/blocks/image.blade.php
@twillBlockTitle('Image')
@twillBlockIcon('text')
@twillBlockGroup('app')
 
<x-twill::medias
    name="highlight"
    label="Highlight"
/>

laravel-workspace % cat resources/views/site/blocks/image.blade.php 
<div class="py-8 mx-auto max-w-2xl flex items-center">
    <img src="{{$block->image('highlight', 'desktop')}}"/>
</div>
```

此外,image block 要生效還需要調整 config/twill.php

```
% cat config/twill.php 
<?php

return [
    'block_editor' => [
        'crops' => [ 
            'highlight' => [
                'desktop' => [
                    [
                        'name' => 'desktop',
                        'ratio' => 16 / 9,
                    ],
                ],
                'mobile' => [
                    [
                        'name' => 'mobile',
                        'ratio' => 1,
                    ],
                ],
            ],
        ],
    ],
];
```

接著,還要幫前後台產的文章添加 CSS 效果 `@vite('resources/css/app.css')` ,添加方式是修改 `resources/views/site/layouts/block.blade.php` 跟 `resources/views/site/page.blade.php`

```
laravel-workspace % cat resources/views/site/layouts/block.blade.php 
<!doctype html>
<html lang="en">
<head>
    <title>#madewithtwill website</title>
    @vite('resources/css/app.css') 
</head>
<body>
<div>
    @yield('content')
</div>
</body>
</html>

laravel-workspace % cat resources/views/site/page.blade.php
<!doctype html>
<html lang="en">
<head>
    <title>{{ $item->title }}</title>
    @vite('resources/css/app.css') 
</head>
<body>
<div class="mx-auto max-w-2xl">
    {!! $item->renderBlocks() !!}
</div>
</body>
</html>
```

此外,還要用 npm 工具編譯出 resources/css/app.css:

```
% nvm use v20
Now using node v20.9.0 (npm v10.3.0)

% npm install        

added 23 packages, and audited 24 packages in 577ms

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

laravel-workspace % tree resources/css/       
resources/css/
└── app.css

1 directory, 1 file
```

如此,在 Twill 後台編輯文章時,就可以在 Block editor 操作下增加文字區塊、圖片等等的功能。

不過,直到現在前台機制還沒打通,尚未提供前台 routing rule 等顯示前台網頁,先建立個 PageDisplayController 來處理:

```
% php artisan make:controller PageDisplayController

   INFO  Controller [app/Http/Controllers/PageDisplayController.php] created successfully.  
```

把 PageDisplayController 更新為可以接一個參數,並且立刻把它印出 debug 訊息:

```
% cat app/Http/Controllers/PageDisplayController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;

class PageDisplayController extends Controller
{
    public function show(string $slug): View
    {
        dd($slug);
    }
}
```

接著,再把 routing 設置好:

```
% cat routes/web.php 
<?php

use Illuminate\Support\Facades\Route;

//Route::get('/', function () {
//    return view('welcome');
//});

Route::get('{slug}', [\App\Http\Controllers\PageDisplayController::class, 'show'])->name('frontend.page'); 
```

如此在前台瀏覽網頁就會看到:

最後,再把 PageDisplayController 調整成顯示正確的資料:

```
laravel-workspace % cat app/Http/Controllers/PageDisplayController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;
use App\Repositories\PageRepository;

class PageDisplayController extends Controller
{
    //public function show(string $slug): View
    //{
    //    dd($slug);
    //}

    public function show(string $slug, PageRepository $pageRepository): View
    {
        $page = $pageRepository->forSlug($slug);
 
        if (!$page) {
            abort(404);
        }
 
        return view('site.page', ['item' => $page]);
    }
}
```

如此瀏覽前台時,例如 http://localhost:8000/hello-world 就可以顯示網頁內容了。

最後,如果碰到前台圖片沒有顯示出來的部分,則是留意拖拉建立圖片時,需要依照不同裝置版型做設定,例如 PC 瀏覽時看不到圖片,那應當是少設定的 desktop 的設置:

```
laravel-workspace % cat resources/views/site/blocks/image.blade.php
<div class="py-8 mx-auto max-w-2xl flex items-center">
    <img src="{{$block->image('highlight', 'desktop')}}"/>
</div>
```

需留意在 block editor 時,其 Image 拖拉進去時,有沒有 `desktop crop` 的描述

2024年4月11日 星期四

PHP 開發筆記 - 修正 osTicket v1.18.1 信件處理流程 (Mail Parser / Mail Fetch / MIMEDecode)


經歷一陣子的追蹤,發現 osTicket v1.18.1 的信件處理流程更新的不錯,但在一個關鍵的格式拼裝上出了錯誤,目前就發個 PR 讓團隊評估是否能修正了

這陣子研究 osTicket v1.18.1 時,信件處理分成兩個部分,一個是信件下載,另一個是信件解析。之前設法先把他信件架在過程中,埋了一個機制把信件存起來,接著去研究它的 MIMEDecode 流程,並猜測這邊要補強,實際上是他下載信件後,要組成 MIME 格式有問題,才使得信件解析有問題


        public function getRawEmail(int $i) {
-           return $this->getRawHeader($i) . $this->getRawContent($i);
+           return trim($this->getRawHeader($i)) . "\r\n\r\n" . $this->getRawContent($i);
        }

透過這次也研究了 osTicket v1.18 採用了 laminas-mail 來處理信件下載流程,整體上跑 api/cron.php 可以看到:

# php8.1 cron.php
@class.mail.php - Imap class: __construct
@Storage/Imap.php: __construct 
@Storage/Imap.php: selectFolder(INBOX) 
@Storage/Imap.php: countMessages()
@Storage/Imap.php: getRawHeader(1, , 0)
@Storage/Imap.php: getRawContent(1, )
@class.mail.php, Imap class: markAsSeen(1)
@Storage/Imap.php: setFlags(1, Array)
@class.mail.php, Imap class: expunge()
@Storage/Imap.php:close()

而在 class.mail.php 在 v1.16 是看不到的,可在 osTicket/osTicket/blob/1.16.x/include/class.mailfetch.php 觀看直接用 PHP imap_* 系列的函式做事,而在 v1.17 起多了 class.mail.php 檔案,在其檔案內可以看到 getRawEmail() 的實作分成 getRawHeader() + getRawContent() 

再往下追就會發現架構也變得漂亮。趁這次多了解了 osTicket ,而為了這次任務,解題思維:
  1. 設法用 Docker 建立 osTicket 測試環境
  2. 設法在 osTicket 處理信件流程中,取出一個 eml 檔案實驗
  3. 設法在 osTicket 架構下,建立匯入 eml 檔案作為重現 MIMEDecode 的測試
  4. 設法直接用 MIMEDecode 測試流程
  5. 追蹤 osTicket IMAP 信件下載流程
沒想到最後一步才發現問題的根源,若一開始先懷疑信件下載流程,應當可以秒解任務。後續再看送出的 PR 有沒緣被收了

2024年3月27日 星期三

PHP 開發筆記 - 使用 Docker 在 Ubuntu 22.04 和 PHP 8.1 架設 osTicket v1.18.1 環境,研究 Mail Parser 流程

接續上篇追蹤 osTicket 信件處理流程的筆記,這次用 Docker 包裝可運行和測試的獨立環境,主要採用 Ubuntu 22.04 與 PHP 8.1,並且規劃方式,建構出備份原始信件的架構,以及可反覆解析信件的流程。

首先是 Dockerfile ,主要設計從 GitHub.com 取出 v1.18.1.zip 並解壓縮 /var/www/osticket-v1.18.1,其餘是安裝相關套件和資料庫的帳號建立等等,最後再把 MySQL, PHP-FPM 和 nginx 運行起來,並預設把 nginx logs 寫到 stdout 觀看:

RUN wget https://github.com/osTicket/osTicket/releases/download/v1.18.1/osTicket-v1.18.1.zip -O /tmp/osticket.zip \
    && unzip /tmp/osticket.zip -d /var/www/ \
    && mv /var/www/upload /var/www/osticket-v1.18.1 \
    && cp /var/www/osticket-v1.18.1/include/ost-sampleconfig.php /var/www/osticket-v1.18.1/include/ost-config.php \
    && chown -R www-data:www-data /var/www/osticket-v1.18.1 \
    && rm /tmp/osticket.zip

...

RUN service mysql start && \
    mysql -e "CREATE USER 'developer'@'%' IDENTIFIED BY '12345678';" && \
    mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'developer'@'%' WITH GRANT OPTION;" && \
    mysql -e "CREATE DATABASE osticket_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" && \
    mysql -e "GRANT ALL PRIVILEGES ON osticket_dev.* TO 'developer'@'%';" && \
    mysql -e "FLUSH PRIVILEGES;" 

...

CMD service mysql start && service php8.1-fpm start && service nginx start && tail -f /var/log/nginx/access.log /var/log/nginx/error.log;

接下來更動 include/class.mailfetch.php 檔案,讓他下載信件時,可以存一份在 /tmp 方便後續使用:

@file_put_contents("/tmp/debug-mail.$i", $this->mbox->getRawEmail($i));

最後,弄一隻 api/cron-dev.php 檔案,可以指定 RawMail 的格式路徑,從指定位置讀進來解析信件,如此靠 api/cron-dev.php 就可以輕鬆不斷實驗 MIMEDecode 流程:

# php api/cron-dev.php 
[INFO] Input: /tmp/debug-mail
[INFO] Input File not found

# php api/cron-dev.php /tmp/debug-mail.1
[INFO] Input: /tmp/debug-mail.1
Ticket Object
(
    [ht] => Array
        (
            [ticket_id] => 2
            [ticket_pid] => 
...
            [lastupdate] => 2024-03-27 21:35:52
            [created] => 2024-03-27 21:35:52
            [updated] => 2024-03-27 21:35:52
            [topic] => 
            [staff] => 
            [user] => User Object
                (
                    [ht] => Array
                        (
                            [id] => 2
                            [org_id] => 0
                            [default_email_id] => 2
                            [status] => 0
                            [name] => UserName
                            [created] => 2024-03-27 21:35:52
                            [updated] => 2024-03-27 21:35:52
                            [default_email] => UserEmailModel Object
                                (
                                    [ht] => Array
                                        (
                                            [id] => 2
                                            [user_id] => 2
                                            [flags] => 0
                                            [address] => user@example.com
...

PHP 開發筆記 - 追蹤 osTicket v1.18.1 在 PHP8 環境處理信件的流程

公司用 osTicket 系統幾年了,最近把環境升級到 PHP8 和最新版 osTicket v1.18.1 後,同事回報踩到信件內容的 cid 圖片沒正常處理,就先追蹤一下信件處理流程,主要先追到 MIMEDecode 即可。

從官網文件 POP3/IMAP Settings Guide 得知,信件處理的觸發有從 crontab 和 api 的管道,目前就從 crontab 來追:

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.6 LTS
Release:        20.04
Codename:       focal

$ php -v
PHP 8.2.17 (cli) (built: Mar 16 2024 08:41:44) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.17, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.17, Copyright (c), by Zend Technologies

$ php api/cron.php

接著開始看 api/cron.php 程式碼,大概追到 include/class.cron.php 時,就可以看到 osTicket\Mail\Fetcher::run(); 和 include/class.email.php 檔案了,從 include/class.mailfetch.php 可以看到:

```php
 78     function processMessage(int $i, array $defaults = []) {
 79         try {
 80             // Please note that the returned object could be anything from
 81             // ticket, task to thread entry or a boolean.
 82             // Don't let TicketApi call fool you!
 83             return $this->getTicketsApi()->processEmail(
 84                     $this->mbox->getRawEmail($i), $defaults);
 85         } catch (\TicketDenied $ex) {
 86             // If a ticket is denied we're going to report it as processed
 87             // so it can be moved out of the Fetch Folder or Deleted based
 88             // on the MailBox settings.
 89             return true;
 90         } catch (\EmailParseError $ex) {
 91             // Upstream we try to create a ticket on email parse error - if
 92             // it fails then that means we have invalid headers.
 93             // For Debug purposes log the parse error + headers as a warning
 94             $this->logWarning(sprintf("%s\n\n%s",
 95                         $ex->getMessage(),
 96                         $this->mbox->getRawHeader($i)));
 97         }
 98         return false;
 99     }
```

接著在 processEmail 前埋一個 @file_put_contents('/tmp/debug-mail', print_r($this->mbox->getRawEmail($i), true)); ,如此處理信件時,原始格式就會被記錄在 /tmp/debug-mail 裡,下一刻就能再重現信件格式的處理流程

小改後面:

$ sudo cp api/cron.php  api/cron-debug.php
$ tail -n 5 api/cron-debug.php 
//LocalCronApiController::call();
//
$obj = new \TicketApiController('cli');
print_r($obj->processEmail(file_get_contents('/tmp/debug-mail'), []));
?>

如此運行時就可以看資訊:

$ php api/cron-debug.php 
Ticket Object
(
    [ht] => Array
        (
            [ticket_id] => #
            [ticket_pid] => 
            [number] => #####
...

接著要來研究信件處理流程,就來到了 include/api.tickets.php 檔案:

```php
    function processEmail($data=false, array $defaults = []) {

        try {
            if (!$data)
                $data = $this->getEmailRequest();
            elseif (!is_array($data))
                $data = $this->parseEmail($data);
            print_r($data);
        } catch (Exception $ex)  {
            throw new EmailParseError($ex->getMessage());
        }
...
```

繼續追 include/class.api.php 檔案:

```php
238     function parseRequest($stream, $format, $validate=true) {
239         $parser = null;
240         switch(strtolower($format)) {
241             case 'xml':
242                 if (!function_exists('xml_parser_create'))
243                     return $this->exerr(501, __('XML extension not supported'));
244                 $parser = new ApiXmlDataParser();
245                 break;
246             case 'json':
247                 $parser = new ApiJsonDataParser();
248                 break;
249             case 'email': 
250                 $parser = new ApiEmailDataParser();
251                 break;
252             default:
253                 return $this->exerr(415, __('Unsupported data format'));
254         }
255         
256         if (!($data = $parser->parse($stream)) || !is_array($data)) {
257             $this->exerr(400, $parser->lastError());
258         }
259         
260         //Validate structure of the request.
261         if ($validate && $data)
262             $this->validate($data, $format, false);
263         
264         return $data;
265     }
266     
267     function parseEmail($content) {
268         return $this->parseRequest($content, 'email', false);
269     }
```

繼續追 include/class.mailparse.php 檔案,主要是追蹤 EmailDataParser -> Mail_Parse -> Mail_mimeDecode ,最後就是 include/pear/Mail/mimeDecode.php 的處理 MIMEDecode 的實作了。

剩下的工作就是研究它解析原始信件的流程,看看是 MIMEDecode 是否少了遞迴解,還是有什麼限制了:

2023年10月3日 星期二

PHP 開發筆記 - 用 Matomo Analytics HTTP API 取代 GA4 Measurement Protocol 服務 @ Ubuntu 22.04


由於 Google Analytics 4 在後端端數據搜集情境已殘,可能起源於 GA4 著重隱私等設計(?),現況數據收集已強調必須先從 Web or App 開始,透過 JS SDK 或 APP SDK 做事,而後端回報為輔助而已,在 Web & App 端可以做隱私宣告。如此,使得純後端回報幾乎無法妥善使用 GA4 的功能,包括無法區分 new user / old user 屬性等等,也不像 GA3 可以把 remote client 的 IP 回報出去而顯示使用者的世界全貌。

架設 Matomo 方式還滿簡單的,就 Web Server + PHP + MySQL ,並且官方也有 docker 安置方式:

由於看到別人的文章提到要記得設置定期分析任務,不然久久登入網頁會很卡的,推論也是從網頁端發送查詢資料,過程中也一同處理資料分析。這個用瀏覽器時,從開發者工具中也可以看到定期在發送 ajax 的 requests。


目前就先弄個 Matomo project 出來,根據上頭的指示很簡單就埋好 js script code 去追蹤網站流量,可以看得到 Matomo 比 GA 提供更多細膩的資訊,這就像隱私的那些規劃,在 GA 上頭只能很粗略地觀看到“趨勢”,而在 Matomo 上頭,則是可以細到把指定的 client 展開出他的 Profile:


我認為跟 GA 相比有滿明顯的設計差距。

接著,關於透過 REST API 回報數據與查詢回報的方式,可以參考:

回報 PageView/Event:

% cat test.php
require 'report.php';

print_r(matomoPageview(
[
[
'url' => 'http://localhost/page1',
'action_name' => 'PageView 01 - Title',
'uid' => '123456789012',
//'cip' => getUserIP(),
],
[
'url' => 'http://localhost/page2',
'action_name' => 'PageView 02 - Title',
'uid' => '123456789012',
//'cip' => getUserIP(),
],
[
'url' => 'http://localhost/page3',
'action_name' => 'PageView 03 - Title',
'uid' => '123456789012',
//'cip' => getUserIP(),
'e_c' => 'Service',
'e_a' => 'Auth',
'e_n' => 'Login',
],

]
,'idSite'
,'MatomoAPI'
));

% php test.php
Array
(
    [status] => 1
    [error] => 
    [info] => Array
        (
            [0] => Array
                (
                    [apiResult] => {"status":"success","tracked":1,"invalid":0}
                )

        )

)

得到 Report:

% cat test.php
require 'report.php';

print_r(matomoQueryReport(
        [   
                'idSite' => 1,
                'period' => 'day',
                'date' => '2023-10-01,2023-10-03',
                'method' => 'VisitsSummary.get',
        ]   
        , 'https://your-matomo.example.com/'
        , 'access_token'
));

% php test.php
Array
(
    [status] => 1
    [data] => Array
        (
            [0] => Array
                (
                    [2023-10-01] => Array
                        (
                        )

                    [2023-10-02] => Array
                        (
                        )

                    [2023-10-03] => Array
                        (
                            [nb_uniq_visitors] => 1
                            [nb_users] => 1
                            [nb_visits] => 1
                            [nb_actions] => 2
                            [nb_visits_converted] => 0
                            [bounce_count] => 0
                            [sum_visit_length] => 1
                            [max_actions] => 2
                            [bounce_rate] => 0%
                            [nb_actions_per_visit] => 2
                            [avg_time_on_site] => 1
                        )

                )

        )

    [error] => 
    [info] => Array
        (
        )

)

2023年9月20日 星期三

Windows 開發筆記 - 使用 Command Line / CMD / ssh 與 PHP 8.2 / Composer / Git / VIM 開發環境 @ Windows 11

幫同事看了一下 Windows 開發環境,要在此環境使用 PHP8.2 與 PHP Laravel v10 framework,由於之前採用 xampp 管理套件被環境變數卡住。這些問題描述,瞬間拉回到學生時代在那邊設置 Windows %PATH% 環境變數 XD 我也忘了那時在幹嘛?推論是配置 Java 環境吧

目前就把手邊的 Windows 11 筆電拿來遠端,但懶得打開它。並且實際在 command line 測試會碰到幾個問題。

1. 從 https://windows.php.net/download/ 下載 VS16 x64 Non Thread Safe ,並解壓在 C:\php 目錄中,必須在設置 php.ini 。想要知道自己的 php.ini 位置,可以用 php.exe --ini

C:\php>php.exe --ini 
Configuration File (php.ini) Path: 
Loaded Configuration File:         (none)
Scan for additional .ini files in: (none)
Additional .ini files parsed:      (none)

接著把 C:\php\php.ini-development 複製到 C:\php\php.ini 使用:

C:\php>copy php.ini-development php.ini     
複製了         1 個檔案。

後續就編輯 php.ini 開啟一些項目,在此就靠 C:\cygwin64\bin\vim.exe 當編輯器(若碰到滑鼠圈選文字難複製,記得關掉滑鼠模式 :set mouse-=a),主要打開一些 php.ini 註解:

; Directory in which the loadable extensions (modules) reside.
; https://php.net/extension-dir
;extension_dir = "./"
; On windows:
extension_dir = "ext"
; ...
extension=curl
extension=fileinfo
extension=gd
extension=intl
extension=mbstring
extension=exif
extension=mysqli
extension=openssl
extension=sqlite3

C:\php>php.exe --ini 
Configuration File (php.ini) Path: 
Loaded Configuration File:         C:\php\php.ini
Scan for additional .ini files in: (none)
Additional .ini files parsed:      (none)

2. 下載 composer 後,預設會失敗:

C:\php>php.exe composer.phar self-update

In Factory.php line 648:
                                                                                                                          The openssl extension is required for SSL/TLS protection but is not available. If you can not enable the openssl extension, you can   
   disable this error, at your own risk, by setting the 'disable-tls' option to true.                          

self-update [-r|--rollback] [--clean-backups] [--no-progress] [--update-keys] [--stable] [--preview] [--snapshot] [--1] [--2] [--2.2] [--set-channel-only] [--] [<version>]

設置後:

C:\php>php.exe composer.phar self-update 
You are already using the latest available Composer version 2.6.3 (stable channel).

3. 在管理專案時,透過 C:\php\php.exe composer.phar install 時,會需要 GIT 指令,解法就是去官方安裝一下,安裝完的目錄位置在 C:\Program Files\Git 位置

C:\>"C:\Program Files\Git\bin\git.exe" --version
git version 2.42.0.windows.2

4. 回過頭來,更新環境變數 %PATH% 

C:\>git
'git' 不是內部或外部命令、可執行的程式或批次檔。
C:\>echo %PATH%
C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files\Docker\Docker\resources\bin;C:\WINDOWS\system32\config\systemprofile\AppData\Local\Microsoft\WindowsApps;C:\Users\user\AppData\Local\Microsoft\WindowsApps;
C:\>set PATH=%PATH%;C:\Program Files\Git\bin\
C:\>git --version
git version 2.42.0.windows.2

如此在 Windows 的 command line (PowerShell) 環境下,也可以靠純指令做一點事了

2022年8月8日 星期一

PHP 開發筆記 - 在 Windows 10 上配置 cmd line 開發環境


這個故事說來話長,我本身不會在 Windows 開發,但為了同事只好配置環境出來,交叉比對運作的問題。就筆記一下,這次要做的事就弄到純 cmd line 開發環境:

安裝:

如此,在 cmd 就可以靠純指令工作,像是 composer install ,而在 cmd line 中有 vim 編輯器,可以快速編輯小東西。

2022年8月3日 星期三

PHP 開發筆記 - 關於 CodeIgniter4 starter app / PHP Coding Standards Fixer v3.9.5 / PHP 7.4 @ macOS 與 Windows

之前初探 PHP Coding Standards Fixer (php-cs-fixer) 時,略知預設跑下去已經有一定水準的 Coding Style 制約機制,然而,工作上引導同事在使用時,碰到了一些雷區。

首先是用 CodeIgniter 4 官方文件建立出的資料後,要跑 PHP Coding Standards Fixer 時會出現要修正語法的問題

```
// https://www.codeigniter.com/user_guide/installation/installing_composer.html
% composer create-project codeigniter4/appstarter project-root
% ./vendor/bin/php-cs-fixer fix --dry-run -v .
PHP CS Fixer 3.9.5 Grand Awaiting by Fabien Potencier and Dariusz Ruminski.
PHP runtime: 7.4.30
Loaded config CodeIgniter4 Coding Standards from "/path/./.php-cs-fixer.dist.php".
Using cache file ".php-cs-fixer.cache".
Paths from configuration file have been overridden by paths provided as command arguments.
.....F......................................F.............                                                                            58 / 58 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error
   1) app/Config/Logger.php (ordered_imports)
   2) app/Views/errors/html/error_exception.php (braces, unary_operator_spaces, not_operator_with_successor_space)

Checked all files in 1.104 seconds, 16.000 MB memory used
```

然後故意降版本到 v3.8.0 又可以正常

```
% mv composer.json composer.json-bak
% composer require friendsofphp/php-cs-fixer:3.8.0 --dev
% ./vendor/bin/php-cs-fixer fix --dry-run -v .
PHP CS Fixer 3.8.0 BerSzcz against war! by Fabien Potencier and Dariusz Ruminski.
PHP runtime: 7.4.30
Loaded config default.
Using cache file ".php-cs-fixer.cache".
..........................................................                                                                            58 / 58 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error

Checked all files in 0.303 seconds, 14.000 MB memory used
```

為了避免未來同事不同平台開發的困局,就來安分地寫一下 .php-cs-fixer.dist.php 檔案吧!也順道研究到 CodeIgniter 也有個 CodeIgniter/coding-standard 規範,接著參考 github.com/CodeIgniter/coding-standard#setup 一步一步測試

```
% cat .php-cs-fixer.dist.php
<?php

use CodeIgniter\CodingStandard\CodeIgniter4;
use Nexus\CsConfig\Factory;

return Factory::create(new CodeIgniter4())->forProjects();

% ./vendor/bin/php-cs-fixer fix --dry-run -v .
PHP CS Fixer 3.9.5 Grand Awaiting by Fabien Potencier and Dariusz Ruminski.
PHP runtime: 7.4.30
Loaded config CodeIgniter4 Coding Standards from "/path/./.php-cs-fixer.dist.php".
Using cache file ".php-cs-fixer.cache".
Paths from configuration file have been overridden by paths provided as command arguments.
.....F......................................F.............                                                                            58 / 58 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error
   1) app/Config/Logger.php (ordered_imports)
   2) app/Views/errors/html/error_exception.php (braces, unary_operator_spaces, not_operator_with_successor_space)

Checked all files in 1.104 seconds, 16.000 MB memory used
```

開始先找一下怎樣略過那兩個檔案的用法:

```
% cat .php-cs-fixer.dist.php
<?php
use CodeIgniter\CodingStandard\CodeIgniter4;
use Nexus\CsConfig\Factory;
use PhpCsFixer\Finder;

// https://github.com/codeigniter4/CodeIgniter4/blob/develop/.php-cs-fixer.dist.php
$finder = Finder::create()
->files()
->notPath([
'app/Config/Logger.php',
'app/Views/errors/html/error_exception.php',
]);

// https://github.com/NexusPHP/cs-config/blob/develop/src/Factory.php
//return Factory::create(new CodeIgniter4())->forProjects();
return Factory::create(new CodeIgniter4(), [], [
'finder' => $finder,
])->forProjects();

% ./vendor/bin/php-cs-fixer fix --dry-run .
Loaded config CodeIgniter4 Coding Standards from "/path/./.php-cs-fixer.dist.php".
Using cache file ".php-cs-fixer.cache".

Checked all files in 0.032 seconds, 14.000 MB memory used
```

接著去 Windows 跑:

```
C:\path>.\vendor\bin\php-cs-fixer fix --dry-run .
PHP CS Fixer 3.9.1 Grand Awaiting by Fabien Potencier and Dariusz Ruminski.
PHP runtime: 7.4.30
Loaded config CodeIgniter4 Coding Standards from "C:\path\.\.php-cs-fixer.dist.php".
Using cache file ".php-cs-fixer.cache".
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF          56 / 56 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error

   1) app\Common.php (line_ending)
  ...
  55) tests\_support\Libraries\ConfigReader.php (line_ending)
  56) tests\_support\Models\ExampleModel.php (line_ending)

Checked all files in 1.187 seconds, 16.000 MB memory used
```

最後再加個 line_ending 來處理一下:

```
% cat .php-cs-fixer.dist.php
<?php
use CodeIgniter\CodingStandard\CodeIgniter4;
use Nexus\CsConfig\Factory;
use PhpCsFixer\Finder;

// https://github.com/codeigniter4/CodeIgniter4/blob/develop/.php-cs-fixer.dist.php
$finder = Finder::create()
->files()
->notPath([
'app/Config/Logger.php',
'app/Views/errors/html/error_exception.php',
]);

// https://github.com/NexusPHP/cs-config/blob/develop/src/Factory.php
//return Factory::create(new CodeIgniter4())->forProjects();
return Factory::create(new CodeIgniter4(), [], [
'finder' => $finder,
// https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues/3955
'lineEnding' => PHP_EOL,
])->forProjects();
```

收工!