2020-02-04 01:44:16 +09:00
|
|
|
<?php
|
2020-05-22 23:12:41 +09:00
|
|
|
declare(strict_types=1);
|
2020-02-04 01:44:16 +09:00
|
|
|
|
2020-02-18 02:09:01 +09:00
|
|
|
namespace App\Services;
|
2020-02-04 01:44:16 +09:00
|
|
|
|
|
|
|
use App\Ejaculation;
|
|
|
|
use App\Exceptions\CsvImportException;
|
2020-02-13 01:03:54 +09:00
|
|
|
use App\Rules\CsvDateTime;
|
2020-08-30 13:57:02 +09:00
|
|
|
use App\Rules\FuzzyBoolean;
|
2020-02-05 00:52:13 +09:00
|
|
|
use App\Tag;
|
2020-02-13 01:03:54 +09:00
|
|
|
use App\User;
|
2020-02-04 01:44:16 +09:00
|
|
|
use Carbon\Carbon;
|
2020-05-21 23:13:39 +09:00
|
|
|
use Illuminate\Database\QueryException;
|
2020-02-04 01:44:16 +09:00
|
|
|
use Illuminate\Support\Facades\DB;
|
2020-02-13 01:03:54 +09:00
|
|
|
use Illuminate\Support\Facades\Validator;
|
2020-02-04 01:44:16 +09:00
|
|
|
use League\Csv\Reader;
|
2020-05-21 23:13:39 +09:00
|
|
|
use Throwable;
|
2020-02-04 01:44:16 +09:00
|
|
|
|
|
|
|
class CheckinCsvImporter
|
|
|
|
{
|
2020-05-23 15:35:17 +09:00
|
|
|
/** @var int 取り込み件数の上限 */
|
|
|
|
private const IMPORT_LIMIT = 5000;
|
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
/** @var User Target user */
|
|
|
|
private $user;
|
2020-02-04 01:44:16 +09:00
|
|
|
/** @var string CSV filename */
|
|
|
|
private $filename;
|
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
public function __construct(User $user, string $filename)
|
2020-02-04 01:44:16 +09:00
|
|
|
{
|
2020-02-13 01:03:54 +09:00
|
|
|
$this->user = $user;
|
2020-02-04 01:44:16 +09:00
|
|
|
$this->filename = $filename;
|
|
|
|
}
|
|
|
|
|
2020-05-23 16:26:07 +09:00
|
|
|
/**
|
|
|
|
* インポート処理を実行します。
|
|
|
|
* @return int 取り込んだ件数
|
|
|
|
*/
|
2020-05-22 22:11:49 +09:00
|
|
|
public function execute(): int
|
2020-02-04 01:44:16 +09:00
|
|
|
{
|
|
|
|
// Guess charset
|
2020-02-16 17:01:16 +09:00
|
|
|
$charset = $this->guessCharset($this->filename);
|
2020-02-04 01:44:16 +09:00
|
|
|
|
|
|
|
// 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
|
2020-05-22 22:11:49 +09:00
|
|
|
return DB::transaction(function () use ($csv) {
|
2020-05-23 15:39:28 +09:00
|
|
|
$alreadyImportedCount = $this->user->ejaculations()->where('ejaculations.source', Ejaculation::SOURCE_CSV)->count();
|
2020-02-04 01:44:16 +09:00
|
|
|
$errors = [];
|
|
|
|
|
|
|
|
if (!in_array('日時', $csv->getHeader(), true)) {
|
|
|
|
$errors[] = '日時列は必須です。';
|
2020-02-16 23:17:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!empty($errors)) {
|
|
|
|
throw new CsvImportException(...$errors);
|
2020-02-04 01:44:16 +09:00
|
|
|
}
|
|
|
|
|
2020-05-22 22:11:49 +09:00
|
|
|
$imported = 0;
|
2020-02-04 01:44:16 +09:00
|
|
|
foreach ($csv->getRecords() as $offset => $record) {
|
2020-02-16 14:28:30 +09:00
|
|
|
$line = $offset + 1;
|
2020-05-23 15:56:51 +09:00
|
|
|
if (self::IMPORT_LIMIT <= $alreadyImportedCount + $imported) {
|
2020-05-23 15:35:17 +09:00
|
|
|
$limit = self::IMPORT_LIMIT;
|
|
|
|
$errors[] = "{$line} 行 : インポート機能で取り込めるデータは{$limit}件までに制限されています。これ以上取り込みできません。";
|
|
|
|
throw new CsvImportException(...$errors);
|
|
|
|
}
|
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
$ejaculation = new Ejaculation(['user_id' => $this->user->id]);
|
2020-02-04 01:44:16 +09:00
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
$validator = Validator::make($record, [
|
|
|
|
'日時' => ['required', new CsvDateTime()],
|
|
|
|
'ノート' => 'nullable|string|max:500',
|
|
|
|
'オカズリンク' => 'nullable|url|max:2000',
|
2020-08-30 13:57:02 +09:00
|
|
|
'非公開' => ['nullable', new FuzzyBoolean()],
|
|
|
|
'センシティブ' => ['nullable', new FuzzyBoolean()],
|
2020-02-13 01:03:54 +09:00
|
|
|
]);
|
2020-02-05 00:52:13 +09:00
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
if ($validator->fails()) {
|
|
|
|
foreach ($validator->errors()->all() as $message) {
|
2020-02-16 14:28:30 +09:00
|
|
|
$errors[] = "{$line} 行 : {$message}";
|
2020-02-05 00:52:13 +09:00
|
|
|
}
|
2020-02-13 01:03:54 +09:00
|
|
|
continue;
|
2020-02-05 00:52:13 +09:00
|
|
|
}
|
|
|
|
|
2020-02-13 01:03:54 +09:00
|
|
|
$ejaculation->ejaculated_date = Carbon::createFromFormat('!Y/m/d H:i+', $record['日時']);
|
2020-02-16 15:29:06 +09:00
|
|
|
$ejaculation->note = str_replace(["\r\n", "\r"], "\n", $record['ノート'] ?? '');
|
2020-02-13 01:03:54 +09:00
|
|
|
$ejaculation->link = $record['オカズリンク'] ?? '';
|
2020-02-18 02:03:49 +09:00
|
|
|
$ejaculation->source = Ejaculation::SOURCE_CSV;
|
2020-08-30 13:57:02 +09:00
|
|
|
if (isset($record['非公開'])) {
|
|
|
|
$ejaculation->is_private = FuzzyBoolean::isTruthy($record['非公開']);
|
|
|
|
}
|
|
|
|
if (isset($record['センシティブ'])) {
|
|
|
|
$ejaculation->is_too_sensitive = FuzzyBoolean::isTruthy($record['センシティブ']);
|
|
|
|
}
|
2020-02-05 00:52:13 +09:00
|
|
|
|
2020-02-16 23:09:30 +09:00
|
|
|
try {
|
|
|
|
$tags = $this->parseTags($line, $record);
|
|
|
|
} catch (CsvImportException $e) {
|
|
|
|
$errors = array_merge($errors, $e->getErrors());
|
|
|
|
continue;
|
2020-02-04 01:44:16 +09:00
|
|
|
}
|
2020-02-16 23:09:30 +09:00
|
|
|
|
2020-05-21 23:13:39 +09:00
|
|
|
DB::beginTransaction();
|
|
|
|
try {
|
|
|
|
$ejaculation->save();
|
|
|
|
if (!empty($tags)) {
|
|
|
|
$ejaculation->tags()->sync(collect($tags)->pluck('id'));
|
|
|
|
}
|
|
|
|
DB::commit();
|
2020-05-22 22:11:49 +09:00
|
|
|
$imported++;
|
2020-05-21 23:13:39 +09:00
|
|
|
} catch (QueryException $e) {
|
|
|
|
DB::rollBack();
|
|
|
|
if ($e->errorInfo[0] === '23505') {
|
|
|
|
$errors[] = "{$line} 行 : すでにこの日時のチェックインデータが存在します。";
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
throw $e;
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
DB::rollBack();
|
|
|
|
throw $e;
|
2020-02-16 23:09:30 +09:00
|
|
|
}
|
2020-02-04 01:44:16 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!empty($errors)) {
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException(...$errors);
|
2020-02-04 01:44:16 +09:00
|
|
|
}
|
2020-05-22 22:11:49 +09:00
|
|
|
|
|
|
|
return $imported;
|
2020-02-04 01:44:16 +09:00
|
|
|
});
|
|
|
|
}
|
2020-02-16 17:01:16 +09:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 指定されたファイルを読み込み、文字コードの判定を行います。
|
|
|
|
* @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) {
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
|
2020-02-16 17:01:16 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
$head = fread($fp, $samplingLength);
|
|
|
|
if ($head === false) {
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
|
2020-02-16 17:01:16 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException('文字コード判定に失敗しました。UTF-8 (BOM無し) または Shift_JIS をお使いください。');
|
2020-02-16 17:01:16 +09:00
|
|
|
} else {
|
|
|
|
return $charset;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 1バイト追加で読み込んだら、文字境界に到達して上手く判定できるかもしれない
|
|
|
|
if (feof($fp)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
$next = fread($fp, 1);
|
|
|
|
if ($next === false) {
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException('CSVファイルの読み込み中にエラーが発生しました。');
|
2020-02-16 17:01:16 +09:00
|
|
|
}
|
|
|
|
$head .= $next;
|
|
|
|
}
|
|
|
|
|
2020-02-16 23:17:07 +09:00
|
|
|
throw new CsvImportException('文字コード判定に失敗しました。UTF-8 (BOM無し) または Shift_JIS をお使いください。');
|
2020-02-16 17:01:16 +09:00
|
|
|
} finally {
|
|
|
|
fclose($fp);
|
|
|
|
}
|
|
|
|
}
|
2020-02-16 23:09:30 +09:00
|
|
|
|
|
|
|
/**
|
|
|
|
* タグ列をパースします。
|
|
|
|
* @param int $line 現在の行番号 (1 origin)
|
|
|
|
* @param array $record 対象行のデータ
|
|
|
|
* @return Tag[]
|
|
|
|
* @throws CsvImportException バリデーションエラーが発生した場合にスロー
|
|
|
|
*/
|
|
|
|
private function parseTags(int $line, array $record): array
|
|
|
|
{
|
|
|
|
$tags = [];
|
2020-05-14 00:27:09 +09:00
|
|
|
foreach (array_keys($record) as $column) {
|
|
|
|
if (preg_match('/\Aタグ\d{1,2}\z/u', $column) !== 1) {
|
|
|
|
continue;
|
2020-02-16 23:21:17 +09:00
|
|
|
}
|
2020-02-16 23:09:30 +09:00
|
|
|
|
2020-02-16 23:21:17 +09:00
|
|
|
$tag = trim($record[$column]);
|
|
|
|
if (empty($tag)) {
|
2020-05-14 00:27:09 +09:00
|
|
|
continue;
|
2020-02-16 23:21:17 +09:00
|
|
|
}
|
|
|
|
if (mb_strlen($tag) > 255) {
|
|
|
|
throw new CsvImportException("{$line} 行 : {$column}は255文字以内にしてください。");
|
2020-02-16 23:09:30 +09:00
|
|
|
}
|
2020-02-16 23:21:17 +09:00
|
|
|
if (strpos($tag, "\n") !== false) {
|
|
|
|
throw new CsvImportException("{$line} 行 : {$column}に改行を含めることはできません。");
|
|
|
|
}
|
2020-05-22 00:27:32 +09:00
|
|
|
if (strpos($tag, ' ') !== false) {
|
|
|
|
throw new CsvImportException("{$line} 行 : {$column}にスペースを含めることはできません。");
|
|
|
|
}
|
2020-02-16 23:21:17 +09:00
|
|
|
|
|
|
|
$tags[] = Tag::firstOrCreate(['name' => $tag]);
|
2020-05-14 00:27:09 +09:00
|
|
|
if (count($tags) >= 32) {
|
|
|
|
break;
|
|
|
|
}
|
2020-02-16 23:09:30 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
return $tags;
|
|
|
|
}
|
2020-02-04 01:44:16 +09:00
|
|
|
}
|