From 8863cdcaf8760ac3d83c1f67dc61fa3b524a7c0c Mon Sep 17 00:00:00 2001 From: polybjorn Date: Tue, 12 May 2026 08:52:00 +0000 Subject: [PATCH] feat(import): accept .txt URL lists alongside OPML/JSON/ZIP (#8818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Alexandre Alapetite --- app/Controllers/importExportController.php | 38 ++++++++++++++++++++++ cli/import-for-user.php | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/Controllers/importExportController.php b/app/Controllers/importExportController.php index 370d12028..51fc80db2 100644 --- a/app/Controllers/importExportController.php +++ b/app/Controllers/importExportController.php @@ -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 .= '' . "\n"; + } + return '' . "\n" + . '' . "\n" + . $outlines + . '' . "\n"; + } + private function ttrssXmlToJson(string $xml): string|false { $table = (array)simplexml_load_string($xml, options: LIBXML_NOBLANKS | LIBXML_NOCDATA); $table['items'] = $table['article'] ?? []; diff --git a/cli/import-for-user.php b/cli/import-for-user.php index 26c69a118..d75ecd3dd 100755 --- a/cli/import-for-user.php +++ b/cli/import-for-user.php @@ -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();