From d6a57f3428555342789865a9f178aeaca3ca4995 Mon Sep 17 00:00:00 2001 From: olorin99 Date: Mon, 11 May 2026 16:19:04 +1000 Subject: [PATCH 1/4] Allow downloading and importing profiles as JSON files. --- .../screens/settings/profile_selection.dart | 70 +++++++++++++++++++ lib/src/utils/platform/platform_native.dart | 19 +++-- lib/src/utils/platform/platform_none.dart | 5 +- lib/src/utils/platform/platform_web.dart | 17 +++-- lib/src/utils/share.dart | 44 +++++++++--- lib/src/widgets/image.dart | 4 +- 6 files changed, 127 insertions(+), 32 deletions(-) diff --git a/lib/src/screens/settings/profile_selection.dart b/lib/src/screens/settings/profile_selection.dart index bb3ad7e5..992944bb 100644 --- a/lib/src/screens/settings/profile_selection.dart +++ b/lib/src/screens/settings/profile_selection.dart @@ -1,11 +1,17 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/profile.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; import 'package:interstellar/src/screens/settings/account_selection.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; @@ -126,6 +132,36 @@ class _ProfileSelectWidgetState extends State<_ProfileSelectWidget> { }, icon: const Icon(Symbols.edit_rounded), ), + LoadingIconButton( + onPressed: () async { + final profile = + (await context.read().getProfile( + profileName, + )).exportReady(); + + final config = await ConfigShare.create( + type: ConfigShareType.profile, + name: profileName, + payload: profile.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '$profileName.json', + defaultDir: ac.defaultDownloadDir, + ); + }, + icon: const Icon(Symbols.download_rounded), + ), IconButton( onPressed: () async { final profile = @@ -182,6 +218,40 @@ class _ProfileSelectWidgetState extends State<_ProfileSelectWidget> { ); getProfiles(); }, + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.profile) return; + + final profile = ProfileOptional.fromJson({ + ...config.payload, + 'name': config.name, + }); + + if (!context.mounted) return; + await context.router.push( + EditProfileRoute( + profile: config.name, + profileList: profileList!, + importProfile: profile, + ), + ); + getProfiles(); + }, + label: Text(l(context).profile_import), + ), ), const SizedBox(height: 16), ], diff --git a/lib/src/utils/platform/platform_native.dart b/lib/src/utils/platform/platform_native.dart index a1ef816b..119ed251 100644 --- a/lib/src/utils/platform/platform_native.dart +++ b/lib/src/utils/platform/platform_native.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; -import 'package:http/http.dart' as http; import 'package:interstellar/src/utils/utils.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/sqlite3.dart'; // get sqlite on native platforms. @@ -11,13 +11,11 @@ Future getSqlite() async { return sqlite3; } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { - final response = await http.get(uri); - // Whether to use bytes property or need to manually write file final useBytes = PlatformIs.mobile; String? filePath; @@ -25,12 +23,11 @@ Future downloadFromUri( try { filePath = await FilePicker.platform.saveFile( fileName: filename, - bytes: useBytes ? response.bodyBytes : null, + bytes: useBytes ? await file.readAsBytes() : null, ); - if (filePath == null) return; + if (filePath == null) return false; } catch (e) { - // If file saver fails, then try to download to downloads directory final dir = await getDownloadsDirectory(); if (dir == null) throw Exception('Downloads directory not found'); @@ -41,7 +38,9 @@ Future downloadFromUri( } if (!useBytes || defaultDir != null) { - final file = File(filePath); - await file.writeAsBytes(response.bodyBytes); + final outFile = File(filePath); + await outFile.writeAsBytes(await file.readAsBytes()); } + + return true; } diff --git a/lib/src/utils/platform/platform_none.dart b/lib/src/utils/platform/platform_none.dart index 52ae9ee2..5e7898f8 100644 --- a/lib/src/utils/platform/platform_none.dart +++ b/lib/src/utils/platform/platform_none.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/common.dart'; // Stub to handle sqlite on different platforms. @@ -7,8 +8,8 @@ Future getSqlite() async { throw UnsupportedError('Unknown pipeline'); } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { diff --git a/lib/src/utils/platform/platform_web.dart b/lib/src/utils/platform/platform_web.dart index 33492d28..9875d629 100644 --- a/lib/src/utils/platform/platform_web.dart +++ b/lib/src/utils/platform/platform_web.dart @@ -1,8 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:http/http.dart' as http; -import 'package:mime/mime.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/wasm.dart'; import 'package:web/web.dart' as web; @@ -14,19 +13,19 @@ Future getSqlite() async { return sqlite; } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { - final response = await http.get(uri); - - final data = base64Encode(response.bodyBytes); - final mimeType = lookupMimeType(uri.toString()); + final data = base64Encode(await file.readAsBytes()); + final mimeType = file.mimeType; final a = web.HTMLAnchorElement() ..href = 'data:$mimeType;base64,$data' - ..download = 'image.${uri.pathSegments.last}' + ..download = filename ..click() ..remove(); + + return true; } diff --git a/lib/src/utils/share.dart b/lib/src/utils/share.dart index 9f2e71be..fe4253ed 100644 --- a/lib/src/utils/share.dart +++ b/lib/src/utils/share.dart @@ -1,11 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:http/http.dart' as http; import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/utils/platform/platform.dart'; import 'package:interstellar/src/utils/utils.dart'; +import 'package:mime/mime.dart'; import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; Future shareUri(Uri uri) async { @@ -31,18 +34,41 @@ Future shareFile(Uri uri, String filename) async { return result; } -Future downloadFile( +Future downloadUri( BuildContext context, Uri uri, String filename, { Directory? defaultDir, }) async { - await downloadFromUri(uri, filename, defaultDir: defaultDir); - if (!context.mounted) return; - scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar( - content: Text(l(context).downloaded_file(filename)), - showCloseIcon: true, - ), - ); + final response = await http.get(uri); + + final mimeType = lookupMimeType(uri.toString()); + final file = XFile.fromData(response.bodyBytes, mimeType: mimeType); + + if (await downloadFromFile(file, filename, defaultDir: defaultDir)) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).downloaded_file(filename)), + showCloseIcon: true, + ), + ); + } +} + +Future downloadFile( + BuildContext context, + XFile file, + String filename, { + Directory? defaultDir, +}) async { + if (await downloadFromFile(file, filename, defaultDir: defaultDir)) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).downloaded_file(filename)), + showCloseIcon: true, + ), + ); + } } diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart index c4192a7c..34937ed1 100644 --- a/lib/src/widgets/image.dart +++ b/lib/src/widgets/image.dart @@ -161,7 +161,7 @@ class _AdvancedImagePageState extends State { actions: [ LoadingIconButton( onPressed: () async { - await downloadFile( + await downloadUri( context, Uri.parse(widget.image.src), widget.image.src.split('/').last, @@ -169,7 +169,7 @@ class _AdvancedImagePageState extends State { ); }, onLongPress: () async { - await downloadFile( + await downloadUri( context, Uri.parse(widget.image.src), widget.image.src.split('/').last, From ad1233fc53fbe9fed45af21ea3bf7c2e10b643ed Mon Sep 17 00:00:00 2001 From: olorin99 Date: Mon, 11 May 2026 16:29:55 +1000 Subject: [PATCH 2/4] Allow downloading and importing filter lists as JSON files. --- .../screens/settings/filter_lists_screen.dart | 168 ++++++++++++------ lib/src/utils/share.dart | 2 - 2 files changed, 115 insertions(+), 55 deletions(-) diff --git a/lib/src/screens/settings/filter_lists_screen.dart b/lib/src/screens/settings/filter_lists_screen.dart index 45801101..68bb5b55 100644 --- a/lib/src/screens/settings/filter_lists_screen.dart +++ b/lib/src/screens/settings/filter_lists_screen.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_select.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; @@ -37,58 +43,82 @@ class _FilterListsScreenState extends State { body: ListView( children: [ ...ac.filterLists.keys.map( - (name) => Row( - children: [ - Expanded( - child: ListTile( - title: Text(name), - onTap: () => context.router.push( - EditFilterListRoute(filterList: name), - ), - trailing: IconButton( - onPressed: () async { - final filterList = context - .read() - .filterLists[name]!; - - final config = await ConfigShare.create( - type: ConfigShareType.filterList, - name: name, - payload: filterList.toJson(), - ); - - if (!context.mounted) return; - var communityName = mbinConfigsCommunityName; - if (communityName.endsWith( - context.read().instanceHost, - )) { - communityName = communityName.split('@').first; - } - - final community = await context - .read() - .api - .community - .getByName(communityName); - - if (!context.mounted) return; - - await context.router.push( - CreateRoute( - initTitle: '[Filter List] $name', - initBody: - 'Short description here...\n\n${config.toMarkdown()}', - initCommunity: community, - ), - ); - }, - icon: const Icon(Symbols.share_rounded), - ), + (name) => ListTile( + title: Text(name), + onTap: () => + context.router.push(EditFilterListRoute(filterList: name)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LoadingIconButton( + onPressed: () async { + final filterList = context + .read() + .filterLists[name]!; + + final config = await ConfigShare.create( + type: ConfigShareType.filterList, + name: name, + payload: filterList.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '$name.json', + defaultDir: ac.defaultDownloadDir, + ); + }, + icon: const Icon(Symbols.download_rounded), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Switch( + IconButton( + onPressed: () async { + final filterList = context + .read() + .filterLists[name]!; + + final config = await ConfigShare.create( + type: ConfigShareType.filterList, + name: name, + payload: filterList.toJson(), + ); + + if (!context.mounted) return; + var communityName = mbinConfigsCommunityName; + if (communityName.endsWith( + context.read().instanceHost, + )) { + communityName = communityName.split('@').first; + } + + final community = await context + .read() + .api + .community + .getByName(communityName); + + if (!context.mounted) return; + + await context.router.push( + CreateRoute( + initTitle: '[Filter List] $name', + initBody: + 'Short description here...\n\n${config.toMarkdown()}', + initCommunity: community, + ), + ); + }, + icon: const Icon(Symbols.share_rounded), + ), + Switch( value: ac.profile.filterLists[name] ?? false, onChanged: (value) { ac.updateProfile( @@ -101,8 +131,8 @@ class _FilterListsScreenState extends State { ); }, ), - ), - ], + ], + ), ), ), ListTile( @@ -110,6 +140,38 @@ class _FilterListsScreenState extends State { title: Text(l(context).filterList_new), onTap: () => context.router.push(EditFilterListRoute(filterList: null)), + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.filterList) return; + + final filterList = FilterList.fromJson({ + ...config.payload, + 'name': config.name, + }); + + if (!context.mounted) return; + await context.router.push( + EditFilterListRoute( + filterList: config.name, + importFilterList: filterList, + ), + ); + }, + label: Text(l(context).filterList_import), + ), ), ], ), diff --git a/lib/src/utils/share.dart b/lib/src/utils/share.dart index fe4253ed..0304b81a 100644 --- a/lib/src/utils/share.dart +++ b/lib/src/utils/share.dart @@ -1,14 +1,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:http/http.dart' as http; import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/utils/platform/platform.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; Future shareUri(Uri uri) async { From 7531a6ed2f3e270011e42b7fcc155d3468408ad1 Mon Sep 17 00:00:00 2001 From: olorin99 Date: Tue, 12 May 2026 16:19:03 +1000 Subject: [PATCH 3/4] Allow downloading and importing feeds as JSON files. --- lib/l10n/app_en.arb | 3 +- .../settings/feed_settings_screen.dart | 69 +++++++++++++++++++ .../screens/settings/filter_lists_screen.dart | 12 +++- .../screens/settings/profile_selection.dart | 13 +++- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55633f45..cfbe59b9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -898,5 +898,6 @@ "type": "String" } } - } + }, + "invalidFile": "Invalid file" } diff --git a/lib/src/screens/settings/feed_settings_screen.dart b/lib/src/screens/settings/feed_settings_screen.dart index 2e2261e4..b51ca0e7 100644 --- a/lib/src/screens/settings/feed_settings_screen.dart +++ b/lib/src/screens/settings/feed_settings_screen.dart @@ -1,5 +1,10 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/feed.dart'; @@ -14,6 +19,8 @@ import 'package:interstellar/src/screens/explore/explore_screen.dart'; import 'package:interstellar/src/screens/feed/feed_agregator.dart'; import 'package:interstellar/src/screens/feed/feed_screen.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/globals.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/context_menu.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; @@ -119,6 +126,33 @@ class _FeedSettingsScreenState extends State { ), icon: const Icon(Symbols.delete_rounded), ), + LoadingIconButton( + onPressed: () async { + final feed = ac.feeds[entry.key]!; + + final config = await ConfigShare.create( + type: ConfigShareType.feed, + name: entry.key, + payload: feed.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '${entry.key}.json', + defaultDir: ac.defaultDownloadDir, + ); + }, + icon: const Icon(Symbols.download_rounded), + ), IconButton( onPressed: () async { final feed = ac.feeds[entry.key]!; @@ -165,6 +199,41 @@ class _FeedSettingsScreenState extends State { leading: const Icon(Symbols.add_rounded), title: Text(l(context).feeds_new), onTap: () => newFeed(context), + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.feed) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + return; + } + + final feed = Feed.fromJson({...config.payload}); + + if (!context.mounted) return; + await context.router.push( + EditFeedRoute(feed: config.name, feedData: feed), + ); + }, + label: Text(l(context).feeds_import), + ), ), ], ), diff --git a/lib/src/screens/settings/filter_lists_screen.dart b/lib/src/screens/settings/filter_lists_screen.dart index 68bb5b55..a89de29b 100644 --- a/lib/src/screens/settings/filter_lists_screen.dart +++ b/lib/src/screens/settings/filter_lists_screen.dart @@ -10,6 +10,7 @@ import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_select.dart'; @@ -155,7 +156,16 @@ class _FilterListsScreenState extends State { final config = ConfigShare.fromJson(json); - if (config.type != ConfigShareType.filterList) return; + if (config.type != ConfigShareType.filterList) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + return; + } final filterList = FilterList.fromJson({ ...config.payload, diff --git a/lib/src/screens/settings/profile_selection.dart b/lib/src/screens/settings/profile_selection.dart index 992944bb..326f2519 100644 --- a/lib/src/screens/settings/profile_selection.dart +++ b/lib/src/screens/settings/profile_selection.dart @@ -11,6 +11,7 @@ import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; import 'package:interstellar/src/screens/settings/account_selection.dart'; +import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; @@ -233,7 +234,17 @@ class _ProfileSelectWidgetState extends State<_ProfileSelectWidget> { final config = ConfigShare.fromJson(json); - if (config.type != ConfigShareType.profile) return; + if (config.type != ConfigShareType.profile) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + context.router.pop(); + return; + } final profile = ProfileOptional.fromJson({ ...config.payload, From bd485e60e39eac3317105cf89ea978af3b7467ab Mon Sep 17 00:00:00 2001 From: olorin99 Date: Thu, 28 May 2026 14:19:33 +1000 Subject: [PATCH 4/4] Add support for downloading feeds as opml files. --- lib/l10n/app_en.arb | 5 +- .../settings/feed_settings_screen.dart | 79 ++++++++++++++----- lib/src/utils/utils.dart | 56 +++++++++++++ 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cfbe59b9..c2cd2f5b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -899,5 +899,8 @@ } } }, - "invalidFile": "Invalid file" + "invalidFile": "Invalid file", + "downloadFeed": "Download feed", + "asJSON": "As JSON", + "asOPML": "As OPML" } diff --git a/lib/src/screens/settings/feed_settings_screen.dart b/lib/src/screens/settings/feed_settings_screen.dart index b51ca0e7..589462ec 100644 --- a/lib/src/screens/settings/feed_settings_screen.dart +++ b/lib/src/screens/settings/feed_settings_screen.dart @@ -127,30 +127,67 @@ class _FeedSettingsScreenState extends State { icon: const Icon(Symbols.delete_rounded), ), LoadingIconButton( - onPressed: () async { - final feed = ac.feeds[entry.key]!; - - final config = await ConfigShare.create( - type: ConfigShareType.feed, - name: entry.key, - payload: feed.toJson(), - ); + onPressed: () async => ContextMenu( + title: l(context).downloadFeed, + items: [ + ContextMenuItem( + title: l(context).asJSON, + onTap: () async { + final feed = ac.feeds[entry.key]!; + + final config = await ConfigShare.create( + type: ConfigShareType.feed, + name: entry.key, + payload: feed.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); - final file = XFile.fromData( - Uint8List.fromList( - jsonEncode(config.toJson()).codeUnits, + if (!context.mounted) return; + await downloadFile( + context, + file, + '${entry.key}.json', + defaultDir: ac.defaultDownloadDir, + ); + if (!context.mounted) return; + context.router.pop(); + }, ), - mimeType: 'application/json', - ); + ContextMenuItem( + title: l(context).asOPML, + onTap: () async { + final feed = ac.feeds[entry.key]!; + + final opml = convertFeedToOPML( + context, + entry.key, + feed, + ); + + final file = XFile.fromData( + Uint8List.fromList(opml.codeUnits), + mimeType: 'application/xml', + ); - if (!context.mounted) return; - await downloadFile( - context, - file, - '${entry.key}.json', - defaultDir: ac.defaultDownloadDir, - ); - }, + if (!context.mounted) return; + await downloadFile( + context, + file, + '${entry.key}.xml', + defaultDir: ac.defaultDownloadDir, + ); + if (!context.mounted) return; + context.router.pop(); + }, + ), + ], + ).openMenu(context), icon: const Icon(Symbols.download_rounded), ), IconButton( diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index c2325d81..5ef9937c 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -8,7 +8,10 @@ import 'package:http/http.dart' as http; import 'package:interstellar/l10n/app_localizations.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/feed.dart'; import 'package:interstellar/src/controller/server.dart'; +import 'package:interstellar/src/models/config_share.dart'; +import 'package:interstellar/src/utils/ap_urls.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -330,3 +333,56 @@ Future cacheRemoteFile(String url) async { return file; } + +Uri? getRssUrl(AppController ac, String name, FeedSource source) { + return switch (ac.serverSoftware) { + ServerSoftware.mbin => Uri.https( + ac.instanceHost, + 'rss?${source == FeedSource.community ? 'magazine' : 'user'}=$name&content=combined', + ), + ServerSoftware.piefed => + source == FeedSource.user + ? null + : Uri.https(ac.instanceHost, 'community/$name/feed'), + ServerSoftware.lemmy => Uri.https( + ac.instanceHost, + 'feeds/${source == FeedSource.community ? 'c' : 'u'}/$name.xml', + ), + }; +} + +String convertFeedToOPML(BuildContext context, String name, Feed feed) { + final ac = context.read(); + var output = ''; + output += ''; + output += + '\n$name\n\n\n'; + + for (final input in feed.inputs) { + if (input.sourceType != FeedSource.community && + input.sourceType != FeedSource.user) { + continue; + } + + final rss = getRssUrl(ac, input.name, input.sourceType); + if (rss == null) continue; + final apUrl = switch (input.sourceType) { + FeedSource.community => Uri.https( + ac.instanceHost, + ac.serverSoftware == ServerSoftware.mbin + ? '/m/${input.name}' + : '/c/${input.name}', + ), + FeedSource.user => Uri.https( + ac.instanceHost, + '/u/${ac.serverSoftware == ServerSoftware.mbin && getNameHost(context, input.name) != ac.instanceHost ? '@' : ''}${input.name}', + ), + _ => null, + }; + output += + '\n'; + } + + output += '\n\n'; + return output; +}