mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-25 20:03:44 +02:00 
			
		
		
		
	Added GitHub starred import (+ general import/export changes)
This commit is contained in:
		
							
								
								
									
										78
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
|  | ||||
| class GeneratedFormItem { | ||||
|   late String message; | ||||
|   late bool required; | ||||
|  | ||||
|   GeneratedFormItem(this.message, this.required); | ||||
| } | ||||
|  | ||||
| class GeneratedFormModal extends StatefulWidget { | ||||
|   const GeneratedFormModal( | ||||
|       {super.key, required this.title, required this.items}); | ||||
|  | ||||
|   final String title; | ||||
|   final List<GeneratedFormItem> items; | ||||
|  | ||||
|   @override | ||||
|   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||
| } | ||||
|  | ||||
| class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|  | ||||
|   final urlInputController = TextEditingController(); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final formInputs = widget.items.map((e) { | ||||
|       final controller = TextEditingController(); | ||||
|       return [ | ||||
|         controller, | ||||
|         TextFormField( | ||||
|           decoration: InputDecoration(helperText: e.message), | ||||
|           controller: controller, | ||||
|           validator: e.required | ||||
|               ? (value) { | ||||
|                   if (value == null || value.isEmpty) { | ||||
|                     return '${e.message} (required)'; | ||||
|                   } | ||||
|                   return null; | ||||
|                 } | ||||
|               : null, | ||||
|         ) | ||||
|       ]; | ||||
|     }).toList(); | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: Text(widget.title), | ||||
|       content: Form( | ||||
|           key: _formKey, | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             children: [...formInputs.map((e) => e[1] as Widget)], | ||||
|           )), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               HapticFeedback.lightImpact(); | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               if (_formKey.currentState?.validate() == true) { | ||||
|                 HapticFeedback.heavyImpact(); | ||||
|                 Navigator.of(context).pop(formInputs | ||||
|                     .map((e) => (e[0] as TextEditingController).value.text) | ||||
|                     .toList()); | ||||
|               } | ||||
|             }, | ||||
|             child: const Text('Continue')) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // TODO: Add support for larger textarea so this can be used for text/json imports | ||||
| @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/pages/add_app.dart'; | ||||
| import 'package:obtainium/pages/apps.dart'; | ||||
| import 'package:obtainium/pages/import_export.dart'; | ||||
| import 'package:obtainium/pages/settings.dart'; | ||||
|  | ||||
| class HomePage extends StatefulWidget { | ||||
| @@ -12,11 +13,12 @@ class HomePage extends StatefulWidget { | ||||
| } | ||||
|  | ||||
| class _HomePageState extends State<HomePage> { | ||||
|   int selectedIndex = 1; | ||||
|   List<int> selectedIndexHistory = []; | ||||
|   List<Widget> pages = [ | ||||
|     const SettingsPage(), | ||||
|     const AppsPage(), | ||||
|     const AddAppPage() | ||||
|     const AddAppPage(), | ||||
|     const ImportExportPage(), | ||||
|     const SettingsPage() | ||||
|   ]; | ||||
|  | ||||
|   @override | ||||
| @@ -24,27 +26,42 @@ class _HomePageState extends State<HomePage> { | ||||
|     return WillPopScope( | ||||
|         child: Scaffold( | ||||
|           appBar: AppBar(title: const Text('Obtainium')), | ||||
|           body: pages.elementAt(selectedIndex), | ||||
|           body: pages.elementAt( | ||||
|               selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), | ||||
|           bottomNavigationBar: NavigationBar( | ||||
|             destinations: const [ | ||||
|               NavigationDestination( | ||||
|                   icon: Icon(Icons.settings), label: 'Settings'), | ||||
|               NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), | ||||
|               NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), | ||||
|               NavigationDestination( | ||||
|                   icon: Icon(Icons.import_export), label: 'Import/Export'), | ||||
|               NavigationDestination( | ||||
|                   icon: Icon(Icons.settings), label: 'Settings'), | ||||
|             ], | ||||
|             onDestinationSelected: (int index) { | ||||
|               HapticFeedback.lightImpact(); | ||||
|               setState(() { | ||||
|                 selectedIndex = index; | ||||
|                 if (index == 0) { | ||||
|                   selectedIndexHistory.clear(); | ||||
|                 } else if (selectedIndexHistory.isEmpty || | ||||
|                     (selectedIndexHistory.isNotEmpty && | ||||
|                         selectedIndexHistory.last != index)) { | ||||
|                   int existingInd = selectedIndexHistory.indexOf(index); | ||||
|                   if (existingInd >= 0) { | ||||
|                     selectedIndexHistory.removeAt(existingInd); | ||||
|                   } | ||||
|                   selectedIndexHistory.add(index); | ||||
|                 } | ||||
|                 print(selectedIndexHistory); | ||||
|               }); | ||||
|             }, | ||||
|             selectedIndex: selectedIndex, | ||||
|             selectedIndex: | ||||
|                 selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, | ||||
|           ), | ||||
|         ), | ||||
|         onWillPop: () async { | ||||
|           if (selectedIndex != 1) { | ||||
|           if (selectedIndexHistory.isNotEmpty) { | ||||
|             setState(() { | ||||
|               selectedIndex = 1; | ||||
|               selectedIndexHistory.removeLast(); | ||||
|             }); | ||||
|             return false; | ||||
|           } | ||||
|   | ||||
							
								
								
									
										242
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class ImportExportPage extends StatefulWidget { | ||||
|   const ImportExportPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<ImportExportPage> createState() => _ImportExportPageState(); | ||||
| } | ||||
|  | ||||
| class _ImportExportPageState extends State<ImportExportPage> { | ||||
|   bool gettingAppInfo = false; | ||||
|  | ||||
|   Future<List<List<String>>> addApps( | ||||
|       MassAppSource source, | ||||
|       List<String> args, | ||||
|       SourceProvider sourceProvider, | ||||
|       SettingsProvider settingsProvider, | ||||
|       AppsProvider appsProvider) async { | ||||
|     var urls = await source.getUrls(args); | ||||
|     await settingsProvider.getInstallPermission(); | ||||
|     List<dynamic> results = await sourceProvider.getApps(urls); | ||||
|     List<App> apps = results[0]; | ||||
|     Map<String, dynamic> errorsMap = results[1]; | ||||
|     for (var app in apps) { | ||||
|       if (appsProvider.apps.containsKey(app.id)) { | ||||
|         errorsMap.addAll({app.id: 'App already added'}); | ||||
|       } else { | ||||
|         await appsProvider.saveApp(app); | ||||
|       } | ||||
|     } | ||||
|     List<List<String>> errors = | ||||
|         errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); | ||||
|     return errors; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     var settingsProvider = context.read<SettingsProvider>(); | ||||
|     var appsProvider = context.read<AppsProvider>(); | ||||
|     return Padding( | ||||
|         padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             ElevatedButton( | ||||
|                 onPressed: appsProvider.apps.isEmpty | ||||
|                     ? null | ||||
|                     : () { | ||||
|                         HapticFeedback.lightImpact(); | ||||
|                         appsProvider.exportApps().then((String path) { | ||||
|                           ScaffoldMessenger.of(context).showSnackBar( | ||||
|                             SnackBar(content: Text('Exported to $path')), | ||||
|                           ); | ||||
|                         }); | ||||
|                       }, | ||||
|                 child: const Text('Obtainium Export')), | ||||
|             const SizedBox( | ||||
|               height: 8, | ||||
|             ), | ||||
|             ElevatedButton( | ||||
|                 onPressed: () { | ||||
|                   HapticFeedback.lightImpact(); | ||||
|                   showDialog( | ||||
|                       context: context, | ||||
|                       builder: (BuildContext ctx) { | ||||
|                         final formKey = GlobalKey<FormState>(); | ||||
|                         final jsonInputController = TextEditingController(); | ||||
|  | ||||
|                         return AlertDialog( | ||||
|                           scrollable: true, | ||||
|                           title: const Text('Import App List'), | ||||
|                           content: Column(children: [ | ||||
|                             const Text( | ||||
|                                 'Copy the contents of the Obtainium export file and paste them into the field below:'), | ||||
|                             Form( | ||||
|                               key: formKey, | ||||
|                               child: TextFormField( | ||||
|                                 minLines: 7, | ||||
|                                 maxLines: 7, | ||||
|                                 decoration: const InputDecoration( | ||||
|                                     helperText: 'Obtainium export data'), | ||||
|                                 controller: jsonInputController, | ||||
|                                 validator: (value) { | ||||
|                                   if (value == null || value.isEmpty) { | ||||
|                                     return 'Please enter your Obtainium export data'; | ||||
|                                   } | ||||
|                                   bool isJSON = true; | ||||
|                                   try { | ||||
|                                     jsonDecode(value); | ||||
|                                   } catch (e) { | ||||
|                                     isJSON = false; | ||||
|                                   } | ||||
|                                   if (!isJSON) { | ||||
|                                     return 'Invalid input'; | ||||
|                                   } | ||||
|                                   return null; | ||||
|                                 }, | ||||
|                               ), | ||||
|                             ) | ||||
|                           ]), | ||||
|                           actions: [ | ||||
|                             TextButton( | ||||
|                                 onPressed: () { | ||||
|                                   HapticFeedback.lightImpact(); | ||||
|                                   Navigator.of(context).pop(); | ||||
|                                 }, | ||||
|                                 child: const Text('Cancel')), | ||||
|                             TextButton( | ||||
|                                 onPressed: () { | ||||
|                                   HapticFeedback.heavyImpact(); | ||||
|                                   if (formKey.currentState!.validate()) { | ||||
|                                     appsProvider | ||||
|                                         .importApps( | ||||
|                                             jsonInputController.value.text) | ||||
|                                         .then((value) { | ||||
|                                       ScaffoldMessenger.of(context) | ||||
|                                           .showSnackBar( | ||||
|                                         SnackBar( | ||||
|                                             content: Text( | ||||
|                                                 '$value App${value == 1 ? '' : 's'} Imported')), | ||||
|                                       ); | ||||
|                                     }).catchError((e) { | ||||
|                                       ScaffoldMessenger.of(context) | ||||
|                                           .showSnackBar( | ||||
|                                         SnackBar(content: Text(e.toString())), | ||||
|                                       ); | ||||
|                                     }).whenComplete(() { | ||||
|                                       Navigator.of(context).pop(); | ||||
|                                     }); | ||||
|                                   } | ||||
|                                 }, | ||||
|                                 child: const Text('Import')), | ||||
|                           ], | ||||
|                         ); | ||||
|                       }); | ||||
|                 }, | ||||
|                 child: const Text('Obtainium Import')), | ||||
|             const Divider( | ||||
|               height: 32, | ||||
|             ), | ||||
|             ...sourceProvider.massSources | ||||
|                 .map((source) => TextButton( | ||||
|                     onPressed: () { | ||||
|                       showDialog( | ||||
|                           context: context, | ||||
|                           builder: (BuildContext ctx) { | ||||
|                             return GeneratedFormModal( | ||||
|                                 title: 'Import ${source.name}', | ||||
|                                 items: source.requiredArgs | ||||
|                                     .map((e) => GeneratedFormItem(e, true)) | ||||
|                                     .toList()); | ||||
|                           }).then((values) { | ||||
|                         if (values != null) { | ||||
|                           source.getUrls(values).then((urls) { | ||||
|                             addApps(source, values, sourceProvider, | ||||
|                                     settingsProvider, appsProvider) | ||||
|                                 .then((errors) { | ||||
|                               if (errors.isEmpty) { | ||||
|                                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                                   SnackBar( | ||||
|                                       content: | ||||
|                                           Text('Imported ${urls.length} Apps')), | ||||
|                                 ); | ||||
|                               } else { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return AlertDialog( | ||||
|                                         scrollable: true, | ||||
|                                         title: const Text('Import Errors'), | ||||
|                                         content: Column( | ||||
|                                             crossAxisAlignment: | ||||
|                                                 CrossAxisAlignment.stretch, | ||||
|                                             children: [ | ||||
|                                               Text( | ||||
|                                                 '${urls.length - errors.length} of ${urls.length} Apps imported.', | ||||
|                                                 style: Theme.of(context) | ||||
|                                                     .textTheme | ||||
|                                                     .bodyLarge, | ||||
|                                               ), | ||||
|                                               const SizedBox(height: 16), | ||||
|                                               Text( | ||||
|                                                 'The following Apps had errors:', | ||||
|                                                 style: Theme.of(context) | ||||
|                                                     .textTheme | ||||
|                                                     .bodyLarge, | ||||
|                                               ), | ||||
|                                               ...errors.map((e) { | ||||
|                                                 return Column( | ||||
|                                                     crossAxisAlignment: | ||||
|                                                         CrossAxisAlignment | ||||
|                                                             .stretch, | ||||
|                                                     children: [ | ||||
|                                                       const SizedBox( | ||||
|                                                         height: 16, | ||||
|                                                       ), | ||||
|                                                       Text(e[0]), | ||||
|                                                       Text( | ||||
|                                                         e[1], | ||||
|                                                         style: const TextStyle( | ||||
|                                                             fontStyle: FontStyle | ||||
|                                                                 .italic), | ||||
|                                                       ) | ||||
|                                                     ]); | ||||
|                                               }).toList() | ||||
|                                             ]), | ||||
|                                         actions: [ | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 HapticFeedback.lightImpact(); | ||||
|                                                 Navigator.of(context).pop(null); | ||||
|                                               }, | ||||
|                                               child: const Text('Okay')) | ||||
|                                         ], | ||||
|                                       ); | ||||
|                                     }); | ||||
|                               } | ||||
|                             }); | ||||
|                           }).catchError((e) { | ||||
|                             ScaffoldMessenger.of(context).showSnackBar( | ||||
|                               SnackBar(content: Text(e.toString())), | ||||
|                             ); | ||||
|                           }); | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|                     child: Text('Import ${source.name}'))) | ||||
|                 .toList() | ||||
|           ], | ||||
|         )); | ||||
|   } | ||||
| } | ||||
| @@ -127,112 +127,6 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                           }) | ||||
|                     ], | ||||
|                   ), | ||||
|                   const SizedBox( | ||||
|                     height: 16, | ||||
|                   ), | ||||
|                   Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||
|                     children: [ | ||||
|                       ElevatedButton( | ||||
|                           onPressed: appsProvider.apps.isEmpty | ||||
|                               ? null | ||||
|                               : () { | ||||
|                                   HapticFeedback.lightImpact(); | ||||
|                                   appsProvider.exportApps().then((String path) { | ||||
|                                     ScaffoldMessenger.of(context).showSnackBar( | ||||
|                                       SnackBar( | ||||
|                                           content: Text('Exported to $path')), | ||||
|                                     ); | ||||
|                                   }); | ||||
|                                 }, | ||||
|                           child: const Text('Export App List')), | ||||
|                       ElevatedButton( | ||||
|                           onPressed: () { | ||||
|                             HapticFeedback.lightImpact(); | ||||
|                             showDialog( | ||||
|                                 context: context, | ||||
|                                 builder: (BuildContext ctx) { | ||||
|                                   final formKey = GlobalKey<FormState>(); | ||||
|                                   final jsonInputController = | ||||
|                                       TextEditingController(); | ||||
|  | ||||
|                                   return AlertDialog( | ||||
|                                     scrollable: true, | ||||
|                                     title: const Text('Import App List'), | ||||
|                                     content: Column(children: [ | ||||
|                                       const Text( | ||||
|                                           'Copy the contents of the Obtainium export file and paste them into the field below:'), | ||||
|                                       Form( | ||||
|                                         key: formKey, | ||||
|                                         child: TextFormField( | ||||
|                                           minLines: 7, | ||||
|                                           maxLines: 7, | ||||
|                                           decoration: const InputDecoration( | ||||
|                                               helperText: | ||||
|                                                   'Obtainium export data'), | ||||
|                                           controller: jsonInputController, | ||||
|                                           validator: (value) { | ||||
|                                             if (value == null || | ||||
|                                                 value.isEmpty) { | ||||
|                                               return 'Please enter your Obtainium export data'; | ||||
|                                             } | ||||
|                                             bool isJSON = true; | ||||
|                                             try { | ||||
|                                               jsonDecode(value); | ||||
|                                             } catch (e) { | ||||
|                                               isJSON = false; | ||||
|                                             } | ||||
|                                             if (!isJSON) { | ||||
|                                               return 'Invalid input'; | ||||
|                                             } | ||||
|                                             return null; | ||||
|                                           }, | ||||
|                                         ), | ||||
|                                       ) | ||||
|                                     ]), | ||||
|                                     actions: [ | ||||
|                                       TextButton( | ||||
|                                           onPressed: () { | ||||
|                                             HapticFeedback.lightImpact(); | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                           }, | ||||
|                                           child: const Text('Cancel')), | ||||
|                                       TextButton( | ||||
|                                           onPressed: () { | ||||
|                                             HapticFeedback.heavyImpact(); | ||||
|                                             if (formKey.currentState! | ||||
|                                                 .validate()) { | ||||
|                                               appsProvider | ||||
|                                                   .importApps( | ||||
|                                                       jsonInputController | ||||
|                                                           .value.text) | ||||
|                                                   .then((value) { | ||||
|                                                 ScaffoldMessenger.of(context) | ||||
|                                                     .showSnackBar( | ||||
|                                                   SnackBar( | ||||
|                                                       content: Text( | ||||
|                                                           '$value App${value == 1 ? '' : 's'} Imported')), | ||||
|                                                 ); | ||||
|                                               }).catchError((e) { | ||||
|                                                 ScaffoldMessenger.of(context) | ||||
|                                                     .showSnackBar( | ||||
|                                                   SnackBar( | ||||
|                                                       content: | ||||
|                                                           Text(e.toString())), | ||||
|                                                 ); | ||||
|                                               }).whenComplete(() { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }); | ||||
|                                             } | ||||
|                                           }, | ||||
|                                           child: const Text('Import')), | ||||
|                                     ], | ||||
|                                   ); | ||||
|                                 }); | ||||
|                           }, | ||||
|                           child: const Text('Import App List')) | ||||
|                     ], | ||||
|                   ), | ||||
|                   const Spacer(), | ||||
|                   Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.center, | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'dart:convert'; | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
|  | ||||
| class AppNames { | ||||
|   late String author; | ||||
| @@ -404,6 +405,8 @@ class SourceProvider { | ||||
|     IzzyOnDroid() | ||||
|   ]; | ||||
|  | ||||
|   List<MassAppSource> massSources = [GitHubStars()]; | ||||
|  | ||||
|   // Add more source classes here so they are available via the service | ||||
|   AppSource getSource(String url) { | ||||
|     AppSource? source; | ||||
| @@ -442,5 +445,54 @@ class SourceProvider { | ||||
|         apk.apkUrls.length - 1); | ||||
|   } | ||||
|  | ||||
|   /// Returns a length 2 list, where the first element is a list of Apps and | ||||
|   /// the second is a Map<String, dynamic> of URLs and errors | ||||
|   Future<List<dynamic>> getApps(List<String> urls) async { | ||||
|     List<App> apps = []; | ||||
|     Map<String, dynamic> errors = {}; | ||||
|     for (var url in urls) { | ||||
|       try { | ||||
|         apps.add(await getApp(url)); | ||||
|       } catch (e) { | ||||
|         errors.addAll(<String, dynamic>{url: e}); | ||||
|       } | ||||
|     } | ||||
|     return [apps, errors]; | ||||
|   } | ||||
|  | ||||
|   List<String> getSourceHosts() => sources.map((e) => e.host).toList(); | ||||
| } | ||||
|  | ||||
| abstract class MassAppSource { | ||||
|   late String name; | ||||
|   late List<String> requiredArgs; | ||||
|   Future<List<String>> getUrls(List<String> args); | ||||
| } | ||||
|  | ||||
| class GitHubStars implements MassAppSource { | ||||
|   @override | ||||
|   late String name = 'GitHub Starred Repos'; | ||||
|  | ||||
|   @override | ||||
|   late List<String> requiredArgs = ['Username']; | ||||
|  | ||||
|   @override | ||||
|   Future<List<String>> getUrls(List<String> args) async { | ||||
|     if (args.length != requiredArgs.length) { | ||||
|       throw 'Wrong number of arguments provided'; | ||||
|     } | ||||
|     Response res = | ||||
|         await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); | ||||
|     if (res.statusCode == 200) { | ||||
|       return (jsonDecode(res.body) as List<dynamic>) | ||||
|           .map((e) => e['html_url'] as String) | ||||
|           .toList(); | ||||
|     } else { | ||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|         throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; | ||||
|       } | ||||
|  | ||||
|       throw 'Unable to find user\'s starred repos'; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user