stateRepository->get(); $this->entityManager->persist($state); $now = $this->clock->now(); if (null !== $state->getPausedUntil() && $now < $state->getPausedUntil()) { $report->stoppedBy = 'Feed switched off by TYPO3 until '.$state->getPausedUntil()->format('c'); return $report; } $state->setPausedUntil(null)->setLastRunAt($now); $this->client->startRun(); try { $manifest = $this->client->manifest(); $documents = $this->applyManifest($manifest, $report); $state->setManifestGeneratedAt($manifest->generatedAt); $this->entityManager->flush(); $staleBefore = $refresh ? $now->modify(sprintf('-%d days', self::REFRESH_AFTER_DAYS)) : null; $this->syncRecords($documents, $staleBefore, $report); $this->syncPages($documents, $staleBefore, $ignoreWindow || $this->inNightWindow($now), $report); } catch (ContentFeedUnavailableException $e) { $state->setPausedUntil($e->retryAfter); $report->stoppedBy = $e->getMessage(); } catch (ContentFeedStoppedException $e) { $report->stoppedBy = $e->getMessage(); } if (isset($documents)) { $this->merge(); } $state->setLastError($report->stoppedBy ?? $report->pagePhaseEndedBy); $this->entityManager->flush(); if (null === $report->stoppedBy) { $this->logger->info('Content feed sync finished', $report->toLogContext()); } else { $this->logger->warning('Content feed sync stopped', $report->toLogContext()); } if ($report->isComplete() && $report->changedAnything()) { $this->tripCatalog->logUnmatchedCodes(); } return $report; } /** * Deletes what the manifest no longer lists and brings every listed item's metadata up * to date. Content is not touched here. * * @return array */ private function applyManifest(ContentManifest $manifest, ContentSyncReport $report): array { $stored = $this->documentRepository->findAllByKey(); foreach (array_diff_key($stored, $manifest->items) as $document) { $this->entityManager->remove($document); ++$report->deleted; } $documents = []; foreach ($manifest->items as $key => $item) { $document = $stored[$key] ?? null; if (null === $document) { $document = new ContentDocument($item->type, $item->uid, $item->language, $item->modified); $this->entityManager->persist($document); ++$report->added; } $document->setModified($item->modified)->setSite($item->site)->setUrl($item->url); if ($document->isPage()) { $document ->setTitle($item->title ?? '') ->setBreadcrumb([] === $item->breadcrumb ? null : implode(' › ', $item->breadcrumb)) ->setData(['contentUrl' => $item->contentUrl, 'description' => $item->description, 'detailOf' => $item->detailOf]); } $documents[$key] = ['document' => $document, 'item' => $item]; } return $documents; } /** * @param array $documents */ private function syncRecords(array $documents, ?\DateTimeImmutable $staleBefore, ContentSyncReport $report): void { /** @var array>> $stale type => uid => documents; the records endpoint takes no language */ $stale = []; foreach ($this->dueForFetch($documents, $staleBefore, pages: false) as $document) { $stale[$document->getType()][$document->getUid()][] = $document; } foreach ($stale as $type => $byUid) { foreach (array_chunk(array_keys($byUid), ContentFeedClient::RECORDS_BATCH_SIZE) as $uids) { try { $records = $this->client->records($type, $uids); } catch (ContentRequestFailedException $e) { $this->logger->warning('Content feed: records batch failed, retried next run', ['type' => $type, 'error' => $e->getMessage()]); $report->failed += count($uids); continue; } foreach ($uids as $uid) { foreach ($byUid[$uid] as $document) { $record = $records[$uid] ?? null; if (null === $record) { // Requested but absent: no longer eligible. $this->entityManager->remove($document); ++$report->deleted; continue; } $this->applyRecord($document, $record); ++$report->records; } } $this->entityManager->flush(); } } } /** * @param array $record */ private function applyRecord(ContentDocument $document, array $record): void { $title = $this->bodyBuilder->title($document->getType(), $record); $document ->setData($record) ->setTitle('' !== $title ? $title : $document->getTitle()) // The merged pages are appended in merge(). ->setBodyMd($this->bodyBuilder->build($document->getType(), $record)) ->markFetched(ContentFetchState::Ok, $this->clock->now()); } /** * @param array $documents * * @throws ContentFeedStoppedException */ private function syncPages(array $documents, ?\DateTimeImmutable $staleBefore, bool $unlimited, ContentSyncReport $report): void { $due = $this->dueForFetch($documents, $staleBefore, pages: true); if (!$unlimited && count($due) > self::PAGES_OUTSIDE_WINDOW) { $report->deferred = count($due) - self::PAGES_OUTSIDE_WINDOW; $due = array_slice($due, 0, self::PAGES_OUTSIDE_WINDOW); } $fetched = 0; try { foreach ($due as $document) { $this->fetchPage($document, $report); if (0 === ++$fetched % self::FLUSH_EVERY_PAGES) { $this->entityManager->flush(); } } } catch (PagePhaseEndedException $e) { $report->pagePhaseEndedBy = $e->getMessage(); $this->logger->warning('Content feed: page phase ended early', ['reason' => $e->getMessage()]); } finally { $this->entityManager->flush(); } } private function fetchPage(ContentDocument $document, ContentSyncReport $report): void { $contentUrl = $document->getData()['contentUrl'] ?? null; if (!is_string($contentUrl)) { $document->markFetched(ContentFetchState::Failed, $this->clock->now()); ++$report->failed; return; } $result = $this->client->page($contentUrl); if ($result->transient) { ++$report->failed; return; } if (!$result->isOk()) { $this->logger->notice('Content feed: page failed, not refetched until it changes', [ 'uid' => $document->getUid(), 'contentUrl' => $contentUrl, 'status' => $result->failedStatus, ]); $document->markFetched(ContentFetchState::Failed, $this->clock->now()); ++$report->failed; return; } $markdown = $this->converter->convert($result->html, $document->getUrl() ?? $contentUrl); $document->setBodyMd($markdown)->markFetched('' === $markdown ? ContentFetchState::Empty : ContentFetchState::Ok, $this->clock->now()); ++$report->pages; } /** * Items whose `modified` changed since their last fetch, plus, on a refresh, those * fetched before `$staleBefore`, oldest first. * * @param array $documents * * @return list */ private function dueForFetch(array $documents, ?\DateTimeImmutable $staleBefore, bool $pages): array { $due = []; foreach ($documents as ['document' => $document]) { if ($document->isPage() !== $pages || !$this->entityManager->contains($document)) { continue; } $outdated = null !== $staleBefore && (null === $document->getSyncedAt() || $document->getSyncedAt() < $staleBefore); if ($document->isStale() || $outdated) { $due[] = $document; } } usort($due, static fn (ContentDocument $a, ContentDocument $b): int => ($a->getSyncedAt()?->getTimestamp() ?? 0) <=> ($b->getSyncedAt()?->getTimestamp() ?? 0)); return $due; } /** * Appends every detail page to the record it is the detail page of, and keeps the page * itself out of the index. Recomputed from scratch on every run, so a change or deletion * on either side re-merges. */ private function merge(): void { $documents = $this->documentRepository->findAllByKey(); /** @var array> $pagesByRecord */ $pagesByRecord = []; foreach ($documents as $page) { if (!$page->isPage()) { continue; } $target = null; foreach ($page->getData()['detailOf'] ?? [] as $ref) { $candidate = $documents[ContentDocument::key($ref['type'], $ref['uid'], $page->getLanguage())] ?? null; if (null !== $candidate && ContentFetchState::Ok === $candidate->getFetchState()) { $target = $candidate; break; } } $page->setMergedInto($target?->getRecordRef()); if (null !== $target && ContentFetchState::Ok === $page->getFetchState() && '' !== $page->getBodyMd()) { $pagesByRecord[$target->getKey()][] = $page; } } foreach ($documents as $record) { if ($record->isPage() || ContentFetchState::Ok !== $record->getFetchState() || null === $record->getData()) { continue; } $pages = $pagesByRecord[$record->getKey()] ?? []; usort($pages, static fn (ContentDocument $a, ContentDocument $b): int => $a->getUid() <=> $b->getUid()); $body = $this->bodyBuilder->build($record->getType(), $record->getData()); foreach ($pages as $page) { $body .= "\n\n".$page->getBodyMd(); } $record->setBodyMd(trim($body)); // A record without a reachable page of its own is linkable through its detail page. if (null === $record->getUrl() && [] !== $pages) { $record->setUrl($pages[0]->getUrl())->setSite($record->getSite() ?? $pages[0]->getSite()); } } } private function inNightWindow(\DateTimeImmutable $now): bool { $hour = (int) $now->setTimezone(new \DateTimeZone(self::NIGHT_WINDOW_TIMEZONE))->format('G'); return $hour >= self::NIGHT_WINDOW_FROM_HOUR && $hour < self::NIGHT_WINDOW_TO_HOUR; } }