where(function ($subQuery) use ($like): void { $subQuery ->orWhere('archive_uid', 'like', $like) ->orWhere('title', 'like', $like) ->orWhere('summary', 'like', $like) ->orWhere('author', 'like', $like) ->orWhere('source', 'like', $like) ->orWhere('series', 'like', $like); }); } $total = (clone $builder)->count(); $rows = $builder ->orderByDesc('updated_time') ->offset(($page - 1) * $pageSize) ->limit($pageSize) ->get([ 'archive_uid', 'title', 'summary', 'year', 'author', 'source', 'series', 'tags', 'created_time', 'updated_time', Db::raw('jsonb_array_length(chunks) as chunk_count'), ]) ->all(); return [ 'items' => array_map(fn (object $row): array => $this->listItem($row), $rows), 'total' => (int) $total, 'page' => $page, 'page_size' => $pageSize, ]; } public function detail(string $archiveUid): ?array { $row = Db::table('archives')->where('archive_uid', $archiveUid)->first(); if (!$row) { return null; } return $this->detailItem($row); } public function update(string $archiveUid, array $payload): ?array { if (!$this->detail($archiveUid)) { return null; } $updates = []; foreach (['title', 'summary', 'author', 'source', 'series', 'content', 'raw'] as $field) { if (array_key_exists($field, $payload)) { $updates[$field] = $this->nullableText($payload[$field]); } } if (array_key_exists('year', $payload)) { $year = trim((string) ($payload['year'] ?? '')); if ($year === '') { $updates['year'] = null; } elseif (!preg_match('/^\d{1,4}$/', $year)) { throw new InvalidArgumentException('year must be empty or a 1-4 digit number.'); } else { $updates['year'] = (int) $year; } } if (array_key_exists('tags', $payload)) { $updates['tags'] = json_encode($this->normalizeTags($payload['tags']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (array_key_exists('metadata', $payload)) { $updates['metadata'] = json_encode($this->normalizeMetadata($payload['metadata']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if ($updates !== []) { Db::table('archives')->where('archive_uid', $archiveUid)->update($updates); } return $this->detail($archiveUid); } public function delete(string $archiveUid): bool { return (int) Db::table('archives')->where('archive_uid', $archiveUid)->delete() > 0; } private function listItem(object $row): array { $chunks = $this->decodeJson($row->chunks ?? null, []); return [ 'archive_uid' => (string) $row->archive_uid, 'title' => $row->title, 'summary' => $row->summary, 'year' => $row->year === null ? null : (int) $row->year, 'author' => $row->author, 'source' => $row->source, 'series' => $row->series, 'tags' => $this->decodeJson($row->tags ?? null, []), 'chunk_count' => property_exists($row, 'chunk_count') ? ($row->chunk_count === null ? 0 : (int) $row->chunk_count) : count(is_array($chunks) ? $chunks : []), 'created_time' => $row->created_time, 'updated_time' => $row->updated_time, ]; } private function detailItem(object $row): array { $data = $this->listItem($row); $data['metadata'] = $this->decodeJson($row->metadata ?? null, []); $data['content'] = $row->content; $data['raw'] = $row->raw; $data['chunks'] = $this->decodeJson($row->chunks ?? null, []); return $data; } private function normalizeTags(mixed $value): array { if (is_array($value)) { $items = $value; } else { $text = trim((string) $value); if ($text === '') { return []; } $items = preg_split('/[\r\n,]+/', $text) ?: []; } $tags = []; foreach ($items as $item) { $tag = trim((string) $item); if ($tag !== '') { $tags[] = $tag; } } return array_values(array_unique($tags)); } private function normalizeMetadata(mixed $value): array { if (is_array($value)) { return $value; } $text = trim((string) $value); if ($text === '') { return []; } $decoded = json_decode($text, true); if (!is_array($decoded)) { throw new InvalidArgumentException('metadata must be a JSON object or array.'); } return $decoded; } private function nullableText(mixed $value): ?string { $text = trim((string) $value); return $text === '' ? null : $text; } private function decodeJson(mixed $value, mixed $fallback): mixed { if ($value === null) { return $fallback; } if (is_array($value)) { return $value; } $decoded = json_decode((string) $value, true); return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded; } }