209 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			209 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
declare(strict_types=1);
 | 
						|
 | 
						|
namespace App\Services;
 | 
						|
 | 
						|
use App\Ejaculation;
 | 
						|
use App\Exceptions\CsvImportException;
 | 
						|
use App\Rules\CsvDateTime;
 | 
						|
use App\Tag;
 | 
						|
use App\User;
 | 
						|
use Carbon\Carbon;
 | 
						|
use Illuminate\Database\QueryException;
 | 
						|
use Illuminate\Support\Facades\DB;
 | 
						|
use Illuminate\Support\Facades\Validator;
 | 
						|
use League\Csv\Reader;
 | 
						|
use Throwable;
 | 
						|
 | 
						|
class CheckinCsvImporter
 | 
						|
{
 | 
						|
    /** @var int 取り込み件数の上限 */
 | 
						|
    private const IMPORT_LIMIT = 5000;
 | 
						|
 | 
						|
    /** @var User Target user */
 | 
						|
    private $user;
 | 
						|
    /** @var string CSV filename */
 | 
						|
    private $filename;
 | 
						|
 | 
						|
    public function __construct(User $user, string $filename)
 | 
						|
    {
 | 
						|
        $this->user = $user;
 | 
						|
        $this->filename = $filename;
 | 
						|
    }
 | 
						|
 | 
						|
    public function execute(): int
 | 
						|
    {
 | 
						|
        // Guess charset
 | 
						|
        $charset = $this->guessCharset($this->filename);
 | 
						|
 | 
						|
        // Open CSV
 | 
						|
        $csv = Reader::createFromPath($this->filename, 'r');
 | 
						|
        $csv->setHeaderOffset(0);
 | 
						|
        if ($charset === 'SJIS-win') {
 | 
						|
            $csv->addStreamFilter('convert.mbstring.encoding.SJIS-win:UTF-8');
 | 
						|
        }
 | 
						|
 | 
						|
        // Import
 | 
						|
        return DB::transaction(function () use ($csv) {
 | 
						|
            $alreadyImportedCount = $this->user->ejaculations()->where('ejaculations.source', Ejaculation::SOURCE_CSV)->count();
 | 
						|
            $errors = [];
 | 
						|
 | 
						|
            if (!in_array('日時', $csv->getHeader(), true)) {
 | 
						|
                $errors[] = '日時列は必須です。';
 | 
						|
            }
 | 
						|
 | 
						|
            if (!empty($errors)) {
 | 
						|
                throw new CsvImportException(...$errors);
 | 
						|
            }
 | 
						|
 | 
						|
            $imported = 0;
 | 
						|
            foreach ($csv->getRecords() as $offset => $record) {
 | 
						|
                $line = $offset + 1;
 | 
						|
                if (self::IMPORT_LIMIT <= $alreadyImportedCount + $imported) {
 | 
						|
                    $limit = self::IMPORT_LIMIT;
 | 
						|
                    $errors[] = "{$line} 行 : インポート機能で取り込めるデータは{$limit}件までに制限されています。これ以上取り込みできません。";
 | 
						|
                    throw new CsvImportException(...$errors);
 | 
						|
                }
 | 
						|
 | 
						|
                $ejaculation = new Ejaculation(['user_id' => $this->user->id]);
 | 
						|
 | 
						|
                $validator = Validator::make($record, [
 | 
						|
                    '日時' => ['required', new CsvDateTime()],
 | 
						|
                    'ノート' => 'nullable|string|max:500',
 | 
						|
                    'オカズリンク' => 'nullable|url|max:2000',
 | 
						|
                ]);
 | 
						|
 | 
						|
                if ($validator->fails()) {
 | 
						|
                    foreach ($validator->errors()->all() as $message) {
 | 
						|
                        $errors[] = "{$line} 行 : {$message}";
 | 
						|
                    }
 | 
						|
                    continue;
 | 
						|
                }
 | 
						|
 | 
						|
                $ejaculation->ejaculated_date = Carbon::createFromFormat('!Y/m/d H:i+', $record['日時']);
 | 
						|
                $ejaculation->note = str_replace(["\r\n", "\r"], "\n", $record['ノート'] ?? '');
 | 
						|
                $ejaculation->link = $record['オカズリンク'] ?? '';
 | 
						|
                $ejaculation->source = Ejaculation::SOURCE_CSV;
 | 
						|
 | 
						|
                try {
 | 
						|
                    $tags = $this->parseTags($line, $record);
 | 
						|
                } catch (CsvImportException $e) {
 | 
						|
                    $errors = array_merge($errors, $e->getErrors());
 | 
						|
                    continue;
 | 
						|
                }
 | 
						|
 | 
						|
                DB::beginTransaction();
 | 
						|
                try {
 | 
						|
                    $ejaculation->save();
 | 
						|
                    if (!empty($tags)) {
 | 
						|
                        $ejaculation->tags()->sync(collect($tags)->pluck('id'));
 | 
						|
                    }
 | 
						|
                    DB::commit();
 | 
						|
                    $imported++;
 | 
						|
                } catch (QueryException $e) {
 | 
						|
                    DB::rollBack();
 | 
						|
                    if ($e->errorInfo[0] === '23505') {
 | 
						|
                        $errors[] = "{$line} 行 : すでにこの日時のチェックインデータが存在します。";
 | 
						|
                        continue;
 | 
						|
                    }
 | 
						|
                    throw $e;
 | 
						|
                } catch (Throwable $e) {
 | 
						|
                    DB::rollBack();
 | 
						|
                    throw $e;
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            if (!empty($errors)) {
 | 
						|
                throw new CsvImportException(...$errors);
 | 
						|
            }
 | 
						|
 | 
						|
            return $imported;
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * 指定されたファイルを読み込み、文字コードの判定を行います。
 | 
						|
     * @param string $filename CSVファイル名
 | 
						|
     * @param int $samplingLength ファイルの先頭から何バイトを判定に使用するかを指定
 | 
						|
     * @return string 検出した文字コード (UTF-8, SJIS-win, ...)
 | 
						|
     * @throws CsvImportException ファイルの読み込みに失敗した、文字コードを判定できなかった、または非対応文字コードを検出した場合にスロー
 | 
						|
     */
 | 
						|
    private function guessCharset(string $filename, int $samplingLength = 1024): string
 | 
						|
    {
 | 
						|
        $fp = fopen($filename, 'rb');
 | 
						|
        if (!$fp) {
 | 
						|
            throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
 | 
						|
        }
 | 
						|
 | 
						|
        try {
 | 
						|
            $head = fread($fp, $samplingLength);
 | 
						|
            if ($head === false) {
 | 
						|
                throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
 | 
						|
            }
 | 
						|
 | 
						|
            for ($addition = 0; $addition < 4; $addition++) {
 | 
						|
                $charset = mb_detect_encoding($head, ['ASCII', 'UTF-8', 'SJIS-win'], true);
 | 
						|
                if ($charset) {
 | 
						|
                    if (array_search($charset, ['UTF-8', 'SJIS-win'], true) === false) {
 | 
						|
                        throw new CsvImportException('文字コード判定に失敗しました。UTF-8 (BOM無し) または Shift_JIS をお使いください。');
 | 
						|
                    } else {
 | 
						|
                        return $charset;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                // 1バイト追加で読み込んだら、文字境界に到達して上手く判定できるかもしれない
 | 
						|
                if (feof($fp)) {
 | 
						|
                    break;
 | 
						|
                }
 | 
						|
                $next = fread($fp, 1);
 | 
						|
                if ($next === false) {
 | 
						|
                    throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
 | 
						|
                }
 | 
						|
                $head .= $next;
 | 
						|
            }
 | 
						|
 | 
						|
            throw new CsvImportException('文字コード判定に失敗しました。UTF-8 (BOM無し) または Shift_JIS をお使いください。');
 | 
						|
        } finally {
 | 
						|
            fclose($fp);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * タグ列をパースします。
 | 
						|
     * @param int $line 現在の行番号 (1 origin)
 | 
						|
     * @param array $record 対象行のデータ
 | 
						|
     * @return Tag[]
 | 
						|
     * @throws CsvImportException バリデーションエラーが発生した場合にスロー
 | 
						|
     */
 | 
						|
    private function parseTags(int $line, array $record): array
 | 
						|
    {
 | 
						|
        $tags = [];
 | 
						|
        foreach (array_keys($record) as $column) {
 | 
						|
            if (preg_match('/\Aタグ\d{1,2}\z/u', $column) !== 1) {
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
 | 
						|
            $tag = trim($record[$column]);
 | 
						|
            if (empty($tag)) {
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
            if (mb_strlen($tag) > 255) {
 | 
						|
                throw new CsvImportException("{$line} 行 : {$column}は255文字以内にしてください。");
 | 
						|
            }
 | 
						|
            if (strpos($tag, "\n") !== false) {
 | 
						|
                throw new CsvImportException("{$line} 行 : {$column}に改行を含めることはできません。");
 | 
						|
            }
 | 
						|
            if (strpos($tag, ' ') !== false) {
 | 
						|
                throw new CsvImportException("{$line} 行 : {$column}にスペースを含めることはできません。");
 | 
						|
            }
 | 
						|
 | 
						|
            $tags[] = Tag::firstOrCreate(['name' => $tag]);
 | 
						|
            if (count($tags) >= 32) {
 | 
						|
                break;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return $tags;
 | 
						|
    }
 | 
						|
}
 |