FeedEntryImporter.java

package net.sasasin.sreader.service.feed.ingestion;

import com.rometools.rome.feed.synd.SyndEntry;
import java.net.URI;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.Optional;
import net.sasasin.sreader.domain.ContentHeader;
import net.sasasin.sreader.domain.FeedUrl;
import net.sasasin.sreader.repository.ContentHeaderRepository;
import net.sasasin.sreader.service.article.ArticleUrlCanonicalizer;
import net.sasasin.sreader.service.article.HashIds;
import net.sasasin.sreader.service.extraction.ContentFullTextWriteOutcome;
import net.sasasin.sreader.service.extraction.ContentFullTextWriter;
import net.sasasin.sreader.service.extraction.FeedEntryFullTextExtractor;
import net.sasasin.sreader.service.extraction.TextExtractionOutcome;
import net.sasasin.sreader.service.http.HttpFetchService;
import net.sasasin.sreader.service.http.RedirectResolution;
import net.sasasin.sreader.service.outcome.FailureKind;
import net.sasasin.sreader.service.outcome.FailureStage;
import net.sasasin.sreader.service.outcome.OperationFailure;
import org.springframework.dao.DataAccessException;

/** Maps and persists one parsed feed entry. */
final class FeedEntryImporter {
  private final HttpFetchService httpFetchService;
  private final ArticleUrlCanonicalizer canonicalizer;
  private final ContentHeaderRepository headers;
  private final FeedEntryFullTextExtractor feedTextExtractor;
  private final ContentFullTextWriter fullTextWriter;

  FeedEntryImporter(
      HttpFetchService httpFetchService,
      ArticleUrlCanonicalizer canonicalizer,
      ContentHeaderRepository headers,
      FeedEntryFullTextExtractor feedTextExtractor,
      ContentFullTextWriter fullTextWriter) {
    this.httpFetchService = httpFetchService;
    this.canonicalizer = canonicalizer;
    this.headers = headers;
    this.feedTextExtractor = feedTextExtractor;
    this.fullTextWriter = fullTextWriter;
  }

  FeedEntryImportOutcome importEntry(FeedUrl feedUrl, SyndEntry entry) {
    if (entry.getLink() == null || entry.getLink().isBlank()) {
      return new FeedEntryImportOutcome.MissingLink();
    }

    final URI source;
    try {
      source = URI.create(entry.getLink());
    } catch (IllegalArgumentException e) {
      return new FeedEntryImportOutcome.Failed(
          OperationFailure.of(
              FailureStage.RESOLVE_REDIRECT,
              FailureKind.INVALID_INPUT,
              entry.getLink(),
              "Invalid entry link: " + entry.getLink(),
              e));
    }

    RedirectResolution redirect = httpFetchService.resolveRedirect(source);
    if (redirect instanceof RedirectResolution.Fallback fallback
        && fallback.failure().interrupted()) {
      return new FeedEntryImportOutcome.Failed(fallback.failure());
    }

    URI fetch = redirect.effectiveUri();
    URI canonical = canonicalizer.canonicalize(fetch);

    boolean saveFeedEntryText = feedUrl.fullTextMethod().usesFeedEntryContent();
    String feedTextValue = null;
    if (saveFeedEntryText) {
      TextExtractionOutcome feedTextOutcome = feedTextExtractor.extract(entry);
      switch (feedTextOutcome) {
        case TextExtractionOutcome.Extracted extracted -> feedTextValue = extracted.text();
        case TextExtractionOutcome.NoContent ignored -> {
          // The header is still importable; the explicit NO_CONTENT write outcome is recorded
          // below.
        }
        case TextExtractionOutcome.Skipped skipped ->
            throw new IllegalStateException(
                "Feed entry extraction must not be skipped: " + skipped.reason());
        case TextExtractionOutcome.Failed failed -> {
          return new FeedEntryImportOutcome.Failed(failed.failure());
        }
      }
    }

    ContentHeader header =
        new ContentHeader(
            HashIds.md5(canonical.toString()),
            feedUrl.id(),
            source.toString(),
            fetch.toString(),
            canonical.toString(),
            entry.getTitle(),
            toOffsetDateTime(entry.getPublishedDate()),
            feedTextValue);

    final ContentHeaderUpsertOutcome upsert;
    try {
      upsert = headers.insertOrRefreshFetchUrl(header);
    } catch (DataAccessException e) {
      return new FeedEntryImportOutcome.Failed(
          OperationFailure.of(
              FailureStage.PERSIST_HEADER,
              FailureKind.PERSISTENCE,
              header.canonicalUrl(),
              "Failed to persist content header: " + e.getMessage(),
              e));
    }

    if (upsert == ContentHeaderUpsertOutcome.EXISTING_REFRESHED) {
      return new FeedEntryImportOutcome.AlreadyPresent(redirect);
    }

    Optional<ContentFullTextWriteOutcome> feedTextWrite = Optional.empty();
    if (saveFeedEntryText) {
      try {
        feedTextWrite = Optional.of(fullTextWriter.saveIfAbsent(header, feedTextValue));
      } catch (DataAccessException e) {
        return new FeedEntryImportOutcome.Failed(
            OperationFailure.of(
                FailureStage.PERSIST_FULL_TEXT,
                FailureKind.PERSISTENCE,
                header.canonicalUrl(),
                "Failed to persist feed full text: " + e.getMessage(),
                e));
      }
    }
    return new FeedEntryImportOutcome.Inserted(redirect, feedTextWrite);
  }

  private OffsetDateTime toOffsetDateTime(Date date) {
    return date == null ? null : date.toInstant().atOffset(ZoneOffset.UTC);
  }
}