feat(import): accept .txt URL lists alongside OPML/JSON/ZIP (#8818)

* feat(import): accept .txt URL lists alongside OPML/JSON/ZIP

Detects .txt by extension and wraps the URL list into a minimal OPML
document so the existing import pipeline handles dedup, categories and
feed limits unchanged. Blank lines, `#` comments and a UTF-8 BOM are
skipped; lines that don't parse as URLs are logged and dropped without
aborting the batch.

Works through both `cli/import-for-user.php` and the web import form.

* utf8BOM

* ENT_COMPAT

---------

Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
This commit is contained in:
polybjorn
2026-05-12 08:52:00 +00:00
committed by GitHub
parent d74337deb6
commit 8863cdcaf8
2 changed files with 39 additions and 1 deletions
@@ -98,6 +98,11 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
} elseif ('zip' === $type_file) {
// ZIP extension is not loaded
throw new FreshRSS_ZipMissing_Exception();
} elseif ('txt' === $type_file) {
$contents = file_get_contents($path);
if (is_string($contents)) {
$list_files['opml'][] = self::txtToOpml($contents);
}
} elseif ('unknown' !== $type_file) {
$list_files[$type_file][] = file_get_contents($path);
}
@@ -219,6 +224,8 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
private static function guessFileType(string $filename): string {
if (str_ends_with($filename, '.zip')) {
return 'zip';
} elseif (str_ends_with($filename, '.txt')) {
return 'txt';
} elseif (stripos($filename, 'opml') !== false) {
return 'opml';
} elseif (str_ends_with($filename, '.json')) {
@@ -237,6 +244,37 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
return 'unknown';
}
/**
* Wraps a newline-separated list of feed URLs into a minimal OPML document
* so it can be imported through the existing OPML pipeline.
*/
private static function txtToOpml(string $contents): string {
$utf8BOM = "\xEF\xBB\xBF";
$contents = preg_replace('/^' . $utf8BOM . '/', '', $contents) ?? $contents;
$outlines = '';
foreach (preg_split('/\R/', $contents) ?: [] as $line) {
$url = trim($line);
if ($url === '' || str_starts_with($url, '#')) {
continue;
}
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
$message = 'TXT import: skipping invalid URL “' . $url . '”';
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, $message . "\n");
} else {
Minz_Log::warning($message);
}
continue;
}
$escaped = htmlspecialchars($url, ENT_COMPAT | ENT_XML1, 'UTF-8');
$outlines .= '<outline type="rss" text="' . $escaped . '" xmlUrl="' . $escaped . '" />' . "\n";
}
return '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<opml version="2.0"><body>' . "\n"
. $outlines
. '</body></opml>' . "\n";
}
private function ttrssXmlToJson(string $xml): string|false {
$table = (array)simplexml_load_string($xml, options: LIBXML_NOBLANKS | LIBXML_NOCDATA);
$table['items'] = $table['article'] ?? [];
+1 -1
View File
@@ -27,7 +27,7 @@ if (!is_readable($filename)) {
fail('FreshRSS error: file is not readable “' . $filename . '”');
}
echo 'FreshRSS importing ZIP/OPML/JSON for user “', $username, "”…\n";
echo 'FreshRSS importing ZIP/OPML/JSON/TXT for user “', $username, "”…\n";
$importController = new FreshRSS_importExport_Controller();