FeedTomlService.java
package net.sasasin.sreader.service;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class FeedTomlService {
private final FeedUrlRepository feedUrlRepository;
private final Clock clock;
@Autowired
public FeedTomlService(FeedUrlRepository feedUrlRepository) {
this(feedUrlRepository, Clock.systemDefaultZone());
}
FeedTomlService(FeedUrlRepository feedUrlRepository, Clock clock) {
this.feedUrlRepository = feedUrlRepository;
this.clock = clock;
}
public String exportToml(boolean activeOnly) {
StringBuilder toml = new StringBuilder();
toml.append("schema_version = 2\n");
toml.append("generated_at = \"").append(OffsetDateTime.now(clock)).append("\"\n");
for (FeedUrl feed : feedUrlRepository.findAllForExport(activeOnly)) {
toml.append("\n[[feeds]]\n");
toml.append("url = \"").append(escape(feed.url())).append("\"\n");
toml.append("status = \"").append(feed.status()).append("\"\n");
toml.append("full_text_method = \"")
.append(
feed.fullTextMethod() != null
? feed.fullTextMethod().value()
: FullTextMethod.HTTP.value())
.append("\"\n");
if (FeedStatus.UNSUBSCRIBED.value().equals(feed.status())) {
appendString(toml, "unsubscribe_reason", feed.unsubscribeReason());
if (feed.unsubscribedAt() != null) {
toml.append("unsubscribed_at = \"").append(feed.unsubscribedAt()).append("\"\n");
}
appendString(toml, "note", feed.note());
}
}
return toml.toString();
}
@Transactional
public ImportResult importToml(String toml, ImportOptions options) {
List<ImportFeed> feeds = parse(toml);
ImportCounters counters = new ImportCounters();
List<String> conflicts = new ArrayList<>();
for (ImportFeed feed : feeds) {
var existing = feedUrlRepository.findByUrl(feed.url());
if (existing.isEmpty()) {
counters.inserted++;
if (FeedStatus.UNSUBSCRIBED.value().equals(feed.status())) {
counters.unsubscribed++;
}
if (!options.dryRun()) {
feedUrlRepository.insertFromImport(feed.toFeedUrl());
}
continue;
}
FeedUrl current = existing.get();
String currentMethod =
current.fullTextMethod() != null
? current.fullTextMethod().value()
: FullTextMethod.HTTP.value();
String feedMethod =
feed.fullTextMethod() != null ? feed.fullTextMethod() : FullTextMethod.HTTP.value();
boolean methodChanged = !currentMethod.equals(feedMethod);
if (FeedStatus.ACTIVE.value().equals(current.status())
&& FeedStatus.ACTIVE.value().equals(feed.status())) {
if (!methodChanged) {
counters.unchanged++;
} else {
counters.updated++;
if (!options.dryRun()) {
feedUrlRepository.updateFullTextMethod(feed.url(), feedMethod);
}
}
} else if (FeedStatus.ACTIVE.value().equals(current.status())
&& FeedStatus.UNSUBSCRIBED.value().equals(feed.status())) {
counters.updated++;
counters.unsubscribed++;
if (!options.dryRun()) {
feedUrlRepository.unsubscribe(
feed.url(), feed.unsubscribeReason(), feed.unsubscribedAt(), feed.note());
feedUrlRepository.updateFullTextMethod(feed.url(), feedMethod);
}
} else if (FeedStatus.UNSUBSCRIBED.value().equals(current.status())
&& FeedStatus.UNSUBSCRIBED.value().equals(feed.status())) {
boolean metadataChanged =
!java.util.Objects.equals(current.unsubscribeReason(), feed.unsubscribeReason())
|| !java.util.Objects.equals(current.unsubscribedAt(), feed.unsubscribedAt())
|| !java.util.Objects.equals(current.note(), feed.note());
if (metadataChanged || methodChanged) {
counters.updated++;
if (!options.dryRun()) {
feedUrlRepository.updateUnsubscribedMetadata(
feed.url(), feed.unsubscribeReason(), feed.unsubscribedAt(), feed.note());
if (methodChanged) {
feedUrlRepository.updateFullTextMethod(feed.url(), feedMethod);
}
}
} else {
counters.unchanged++;
}
} else if (FeedStatus.UNSUBSCRIBED.value().equals(current.status())
&& FeedStatus.ACTIVE.value().equals(feed.status())) {
if (options.resubscribe()) {
counters.updated++;
counters.resubscribed++;
if (!options.dryRun()) {
feedUrlRepository.resubscribe(feed.url());
feedUrlRepository.updateFullTextMethod(feed.url(), feedMethod);
}
} else {
counters.conflicts++;
conflicts.add(
"feed["
+ feed.index()
+ "] "
+ feed.url()
+ " is unsubscribed in DB but active in TOML");
}
}
}
return new ImportResult(
counters.inserted,
counters.updated,
counters.unchanged,
counters.unsubscribed,
counters.resubscribed,
counters.conflicts,
List.of(),
conflicts);
}
public List<ImportFeed> parse(String toml) {
List<String> errors = new ArrayList<>();
Map<String, String> root = new HashMap<>();
List<Map<String, String>> feedTables = new ArrayList<>();
Map<String, String> currentFeed = null;
String[] lines = toml.split("\\R", -1);
for (int i = 0; i < lines.length; i++) {
String line = stripComment(lines[i]).trim();
if (line.isEmpty()) {
continue;
}
if ("[[feeds]]".equals(line)) {
currentFeed = new HashMap<>();
feedTables.add(currentFeed);
continue;
}
int equals = line.indexOf('=');
if (equals < 0) {
errors.add("line " + (i + 1) + ": expected key = value");
continue;
}
String key = line.substring(0, equals).trim();
String value = line.substring(equals + 1).trim();
(currentFeed == null ? root : currentFeed).put(key, value);
}
int schemaVersion = 0;
if (!root.containsKey("schema_version")) {
errors.add("schema_version is required");
} else {
String sv = root.get("schema_version");
if ("1".equals(sv)) {
schemaVersion = 1;
} else if ("2".equals(sv)) {
schemaVersion = 2;
} else {
errors.add("unsupported schema_version: " + sv);
}
}
Set<String> seenUrls = new HashSet<>();
List<ImportFeed> feeds = new ArrayList<>();
for (int i = 0; i < feedTables.size(); i++) {
Map<String, String> table = feedTables.get(i);
int index = i + 1;
String rawUrl = stringValue(table.get("url"), "feeds[" + index + "].url", errors);
String url = null;
if (rawUrl == null) {
errors.add("feeds[" + index + "].url is required");
} else {
try {
url = FeedUrlNormalizer.normalizeStrict(rawUrl);
if (!seenUrls.add(url)) {
errors.add(
"feeds[" + index + "].url duplicates another feed after normalization: " + url);
}
} catch (IllegalArgumentException e) {
errors.add("feeds[" + index + "].url: " + e.getMessage());
}
}
String status = stringValue(table.get("status"), "feeds[" + index + "].status", errors);
if (status == null) {
status = FeedStatus.ACTIVE.value();
}
if (!FeedStatus.ACTIVE.value().equals(status)
&& !FeedStatus.UNSUBSCRIBED.value().equals(status)) {
errors.add("feeds[" + index + "].status must be active or unsubscribed: " + status);
}
String reason =
stringValue(
table.get("unsubscribe_reason"), "feeds[" + index + "].unsubscribe_reason", errors);
if (FeedStatus.UNSUBSCRIBED.value().equals(status) && reason == null) {
reason = UnsubscribeReason.OTHER.value();
}
if (reason != null) {
try {
UnsubscribeReason.fromValue(reason);
} catch (IllegalArgumentException e) {
errors.add("feeds[" + index + "].unsubscribe_reason is invalid: " + reason);
}
}
OffsetDateTime unsubscribedAt = null;
String rawUnsubscribedAt =
stringValue(table.get("unsubscribed_at"), "feeds[" + index + "].unsubscribed_at", errors);
if (rawUnsubscribedAt != null) {
try {
unsubscribedAt = OffsetDateTime.parse(rawUnsubscribedAt);
} catch (RuntimeException e) {
errors.add(
"feeds["
+ index
+ "].unsubscribed_at must be an offset date-time: "
+ rawUnsubscribedAt);
}
}
String note = stringValue(table.get("note"), "feeds[" + index + "].note", errors);
String rawFullTextMethod =
stringValue(
table.get("full_text_method"), "feeds[" + index + "].full_text_method", errors);
String fullTextMethod;
if (schemaVersion == 1) {
if (rawFullTextMethod == null) {
fullTextMethod = FullTextMethod.HTTP.value();
} else {
try {
fullTextMethod = FullTextMethod.fromValue(rawFullTextMethod).value();
} catch (IllegalArgumentException e) {
errors.add("feeds[" + index + "].full_text_method is invalid: " + rawFullTextMethod);
fullTextMethod = FullTextMethod.HTTP.value();
}
}
} else {
if (rawFullTextMethod == null) {
fullTextMethod = FullTextMethod.HTTP.value();
} else {
try {
fullTextMethod = FullTextMethod.fromValue(rawFullTextMethod).value();
} catch (IllegalArgumentException e) {
errors.add("feeds[" + index + "].full_text_method is invalid: " + rawFullTextMethod);
fullTextMethod = FullTextMethod.HTTP.value();
}
}
}
if (FeedStatus.ACTIVE.value().equals(status)) {
reason = null;
unsubscribedAt = null;
note = null;
}
if (url != null) {
feeds.add(new ImportFeed(index, url, status, reason, unsubscribedAt, note, fullTextMethod));
}
}
if (!errors.isEmpty()) {
throw new TomlValidationException(errors);
}
return feeds;
}
private void appendString(StringBuilder toml, String key, String value) {
if (value != null) {
toml.append(key).append(" = \"").append(escape(value)).append("\"\n");
}
}
private String stringValue(String value, String field, List<String> errors) {
if (value == null) {
return null;
}
if (value.length() < 2 || value.charAt(0) != '"' || value.charAt(value.length() - 1) != '"') {
errors.add(field + " must be a TOML string");
return null;
}
return unescape(value.substring(1, value.length() - 1), field, errors);
}
private String stripComment(String line) {
boolean inString = false;
boolean escaping = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (escaping) {
escaping = false;
} else if (c == '\\' && inString) {
escaping = true;
} else if (c == '"') {
inString = !inString;
} else if (c == '#' && !inString) {
return line.substring(0, i);
}
}
return line;
}
private String escape(String value) {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
private String unescape(String value, String field, List<String> errors) {
StringBuilder result = new StringBuilder();
boolean escaping = false;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!escaping) {
if (c == '\\') {
escaping = true;
} else {
result.append(c);
}
continue;
}
switch (c) {
case '\\' -> result.append('\\');
case '"' -> result.append('"');
case 'n' -> result.append('\n');
case 'r' -> result.append('\r');
case 't' -> result.append('\t');
default -> {
errors.add(field + " contains unsupported escape: \\" + c);
result.append(c);
}
}
escaping = false;
}
if (escaping) {
errors.add(field + " ends with an incomplete escape");
}
return result.toString();
}
private static class ImportCounters {
int inserted;
int updated;
int unchanged;
int unsubscribed;
int resubscribed;
int conflicts;
}
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,
String status,
String unsubscribeReason,
OffsetDateTime unsubscribedAt,
String note,
String fullTextMethod) {
FeedUrl toFeedUrl() {
return new FeedUrl(
HashIds.md5(url),
url,
status,
unsubscribeReason,
unsubscribedAt,
note,
FullTextMethod.fromValue(fullTextMethod));
}
}
public static class TomlValidationException extends RuntimeException {
private final List<String> errors;
TomlValidationException(List<String> errors) {
super(String.join("; ", errors));
this.errors = List.copyOf(errors);
}
public List<String> errors() {
return errors;
}
}
}