ProbeFeedCommand.java
package net.sasasin.sreader.cli;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import net.sasasin.sreader.domain.FeedEntrySelection;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.service.probe.FullTextProbeService;
import net.sasasin.sreader.service.probe.ProbeOutcome;
import org.springframework.stereotype.Component;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;
@Command(
name = "feed",
description = {
"Fetch a feed, select one entry, extract full text with chosen method, print text.",
"Choose at most one entry selector; default is first entry.",
"For --method feed: uses feed entry content/desc (no article fetch). --xpath invalid with"
+ " feed method.",
"Use --autopagerize-dataset-id with AutoPagerize methods to select a non-active dataset.",
"STDOUT: body text. --verbose: diagnostics to STDERR."
},
mixinStandardHelpOptions = true,
usageHelpWidth = 100)
@Component
public class ProbeFeedCommand implements Callable<Integer> {
private final FullTextProbeService fullTextProbeService;
@Spec private CommandSpec spec;
@Option(
names = "--feed-url",
paramLabel = "<RSS_OR_ATOM_URL>",
description = "RSS or Atom feed URL to probe",
required = true)
private String feedUrl;
@Option(
names = "--method",
paramLabel = "<METHOD>",
description =
"Full text method (feed uses entry body; others fetch article URL from chosen entry)."
+ " Invalid input reports all valid values.",
required = true,
converter = FullTextMethodConverter.class,
completionCandidates = FullTextMethodCandidates.class)
private FullTextMethod method;
@Option(
names = "--xpath",
paramLabel = "<XPATH>",
description = "XPath override (not for feed method)")
private String xpath;
@Option(
names = "--autopagerize-dataset-id",
paramLabel = "<ID>",
description =
"AutoPagerize dataset id (active when omitted). Only valid with AutoPagerize methods.")
private Long autopagerizeDatasetId;
@ArgGroup(
exclusive = true,
multiplicity = "0..1",
heading = "Entry selection (choose at most one; default: first):%n")
private EntrySelectionOptions entrySelection;
@Option(names = "--verbose", description = "Print diagnostics to STDERR")
private boolean verbose;
@Option(
names = {"--output", "-o"},
paramLabel = "<PATH>",
description = "Write text to file instead of STDOUT")
private String output;
@Option(names = "--max-chars", paramLabel = "<N>", description = "Truncate output to N chars")
private Integer maxChars;
public ProbeFeedCommand(FullTextProbeService fullTextProbeService) {
this.fullTextProbeService = fullTextProbeService;
}
@Override
public Integer call() {
try {
ProbeFeedCliRequest request =
ProbeFeedCliRequest.create(
spec,
feedUrl,
method,
selection(),
xpath,
verbose,
output,
maxChars,
autopagerizeDatasetId);
ProbeOutcome outcome =
fullTextProbeService.probeFeed(
request.feedUrl(),
request.method(),
request.selection(),
request.xpath(),
request.autopagerizeDatasetId());
return mapOutcome(outcome, request);
} catch (picocli.CommandLine.ParameterException pe) {
throw pe;
} catch (RuntimeException e) {
spec.commandLine().getErr().println("Error: " + e.getMessage());
return CliExitCodes.EXECUTION_ERROR;
}
}
private FeedEntrySelection selection() {
return entrySelection == null ? FeedEntrySelection.first() : entrySelection.selection();
}
private int mapOutcome(ProbeOutcome outcome, ProbeFeedCliRequest request) {
ProbeOutputWriter writer = new ProbeOutputWriter(spec);
return switch (outcome) {
case ProbeOutcome.Succeeded succeeded ->
writer.writeSucceeded(
succeeded,
request.verbose(),
request.output().orElse(null),
request.maxChars().orElse(null));
case ProbeOutcome.NoContent noContent -> {
if (request.verbose()) {
writer.writeNoContentDiagnostics(noContent.document(), noContent.pagination());
}
yield CliExitCodes.EMPTY_RESULT;
}
case ProbeOutcome.NoMatchingEntry noMatch -> {
spec.commandLine().getErr().println("No matching feed entry: " + noMatch.message());
yield CliExitCodes.NO_MATCHING_ENTRY;
}
case ProbeOutcome.Skipped skipped -> {
spec.commandLine().getErr().println(skipped.message());
yield CliExitCodes.PLAYWRIGHT_DISABLED;
}
case ProbeOutcome.InvalidRequest invalid -> {
spec.commandLine().getErr().println(invalid.message());
yield CliExitCodes.USAGE_ERROR;
}
case ProbeOutcome.Failed failed -> {
if (failed.failure().interrupted()) {
Thread.currentThread().interrupt();
}
if (request.verbose()) {
writer.writeFailureDiagnostics(
null, failed.pagination(), Optional.of(failed.failure().message()));
}
spec.commandLine().getErr().println("Error: " + failed.failure().message());
yield CliExitCodes.EXECUTION_ERROR;
}
};
}
/**
* Optional exclusive group of entry selectors. When the group is omitted, {@link
* FeedEntrySelection#first()} is used by the command.
*/
static final class EntrySelectionOptions {
@Option(
names = "--entry",
converter = EntryPositionSelectionConverter.class,
paramLabel = "<first|latest>",
description = "Select first or latest entry (by date)")
FeedEntrySelection position;
@Option(
names = "--entry-index",
converter = EntryIndexSelectionConverter.class,
paramLabel = "<N>",
description = "0-based index of entry in feed")
FeedEntrySelection index;
@Option(
names = "--entry-title-regex",
converter = EntryTitleRegexSelectionConverter.class,
paramLabel = "<REGEX>",
description = "Select first entry whose title matches regex")
FeedEntrySelection titleRegex;
@Option(
names = "--entry-url-regex",
converter = EntryUrlRegexSelectionConverter.class,
paramLabel = "<REGEX>",
description = "Select first entry whose link matches regex")
FeedEntrySelection urlRegex;
FeedEntrySelection selection() {
List<FeedEntrySelection> specified =
Stream.of(position, index, titleRegex, urlRegex).filter(Objects::nonNull).toList();
if (specified.size() != 1) {
throw new IllegalStateException("Picocli entry selector group invariant violated");
}
return specified.getFirst();
}
}
}