FeedTomlService.java

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

import java.time.Clock;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import net.sasasin.sreader.domain.FeedStatus;
import net.sasasin.sreader.domain.FeedUrl;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.domain.UnsubscribeReason;
import net.sasasin.sreader.repository.FeedUrlRepository;
import net.sasasin.sreader.service.article.HashIds;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * Public TOML import/export facade. Parsing, schema validation, domain mapping, and writing are
 * delegated to focused collaborators; this class orchestrates export and import persistence.
 */
@Service
public class FeedTomlService {
  private final FeedUrlRepository feedUrlRepository;
  private final Clock clock;
  private final FeedTomlReader reader;
  private final FeedTomlWriter writer;
  private final FeedImportPlanner planner;
  private final FeedImportExecutor executor;

  FeedTomlService(
      FeedUrlRepository repository,
      Clock clock,
      FeedTomlReader reader,
      FeedTomlWriter writer,
      FeedImportPlanner planner,
      FeedImportExecutor executor) {
    this.feedUrlRepository = repository;
    this.clock = clock;
    this.reader = reader;
    this.writer = writer;
    this.planner = planner;
    this.executor = executor;
  }

  public String exportToml(boolean activeOnly) {
    return writer.write(feedUrlRepository.findAllForExport(activeOnly), clock);
  }

  @Transactional
  public ImportResult importToml(String toml, ImportOptions options) {
    FeedImportExecutor.Counters counters = new FeedImportExecutor.Counters();
    List<String> conflicts = new ArrayList<>();
    for (ImportFeed feed : reader.read(toml)) {
      FeedUrl current = feedUrlRepository.findByUrl(feed.url()).orElse(null);
      executor.execute(
          planner.plan(current, feed, options.resubscribe()),
          options.dryRun(),
          counters,
          conflicts);
    }
    return new ImportResult(
        counters.inserted,
        counters.updated,
        counters.unchanged,
        counters.unsubscribed,
        counters.resubscribed,
        counters.conflicts,
        List.of(),
        conflicts);
  }

  /** Retained as the existing public parsing entry point. */
  public List<ImportFeed> parse(String toml) {
    return reader.read(toml);
  }

  public record ImportOptions(boolean dryRun, boolean resubscribe) {}

  public record ImportResult(
      int inserted,
      int updated,
      int unchanged,
      int unsubscribed,
      int resubscribed,
      int conflicts,
      List<String> errors,
      List<String> conflictMessages) {}

  public record ImportFeed(
      int index,
      String url,
      FeedStatus status,
      UnsubscribeReason unsubscribeReason,
      OffsetDateTime unsubscribedAt,
      String note,
      FullTextMethod fullTextMethod) {
    public ImportFeed {
      if (status == null) {
        throw new IllegalArgumentException("status must not be null");
      }
      if (fullTextMethod == null) {
        throw new IllegalArgumentException("fullTextMethod must not be null");
      }
      if (status == FeedStatus.ACTIVE) {
        if (unsubscribeReason != null || unsubscribedAt != null || note != null) {
          throw new IllegalArgumentException(
              "active import feed must not have unsubscribe metadata");
        }
      } else if (status == FeedStatus.UNSUBSCRIBED && unsubscribeReason == null) {
        throw new IllegalArgumentException("unsubscribed import feed requires unsubscribeReason");
      }
    }

    FeedUrl toFeedUrl() {
      return new FeedUrl(
          HashIds.md5(url), url, status, unsubscribeReason, unsubscribedAt, note, fullTextMethod);
    }
  }

  public static class TomlValidationException extends RuntimeException {
    private final List<String> errors;

    TomlValidationException(List<String> errors) {
      super(String.join("; ", requireNonEmpty(errors)));
      this.errors = List.copyOf(errors);
    }

    private static List<String> requireNonEmpty(List<String> errors) {
      Objects.requireNonNull(errors, "errors");
      if (errors.isEmpty()) {
        throw new IllegalArgumentException("errors must not be empty");
      }
      return errors;
    }

    public List<String> errors() {
      return errors;
    }
  }
}