diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90c6da22..5e391df7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -96,7 +96,7 @@ jobs: security import certificate.p12 -k build.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain security find-identity - /usr/bin/codesign --force --deep --options runtime -s "$MACOS_SIGN_IDENTITY" build/macos/Build/Products/Release/Wispar.app + /usr/bin/codesign --force --deep --options runtime --entitlements macos/Runner/Release.entitlements -s "$MACOS_SIGN_IDENTITY" build/macos/Build/Products/Release/Wispar.app /usr/bin/codesign --verify --deep --strict --verbose=2 build/macos/Build/Products/Release/Wispar.app - name: Notarize app env: diff --git a/PRIVACY.md b/PRIVACY.md index 3a30742f..c897277b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,15 +1,45 @@ -Wispar is an open-source mobile app that prioritizes user privacy. Our app does not collect any data or access personal information. +# Privacy policy -However, Wispar integrates with external services to enhance functionality. Users should be aware that these services could be collecting information such as IP addresses, device-related data and other data. We encourage users to review the privacy policies of each service for a comprehensive understanding of their data collection practices. +Wispar is an open-source mobile app that prioritizes user privacy. By default, our app does not collect any data or access personal information. -Third-party services used by Wispar: +Wispar does not include any third-party trackers, advertisements, or telemetry (e.g., Google Analytics or Firebase) -Core services: +## Wispar Sync (Optional) + +Wispar offers an optional cloud sync feature called **Wispar Sync**. When this feature is enabled, the following data is collected solely for authentication and synchronization purposes: + +- Email address +- App data necessary for syncing which includes: + - Article metadata for favorited and hidden publications + - Journal metadata for followed and other journals + - Custom feeds parameters + - Saved search queries + - EZproxy known URLs + +This data is **not shared, sold, or used for any other purpose**. + +The Wispar Sync server is hosted on a VPS provided by **Hetzner** in Germany. + +Users retain full control over their data: + +- You can permanently delete your data at any time using the delete cloud account button in the sync settings. While deletion is immediate, your data may persist for up to **7 days** within daily server backups before being permanently overwritten. +- You may also choose to self-host the sync backend, allowing you to keep full ownership and control of your data. + +**If you do not enable Wispar Sync, no data is collected by Wispar.** + + +## Third-party services: + +Wispar integrates with external services to enhance functionality. These services may collect information such as IP addresses, device-related data, and other usage data. + +We encourage users to review the privacy policies of these services for a comprehensive understanding of their data collection practices. + +**Core services:** - Crossref: https://www.crossref.org/operations-and-sustainability/privacy - OpenAlex: https://openalex.org/OpenAlex_privacy_policy.pdf -Optional services (used only when enabled and/or an API key is provided): +**Optional services** (used only when enabled and/or an API key is provided): - Unpaywall (enabled by default): https://unpaywall.org/legal/privacy - Zotero: https://www.zotero.org/support/privacy @@ -19,4 +49,8 @@ Optional services (used only when enabled and/or an API key is provided): Please review the privacy policies of these services to understand how they handle data. Wispar does not have control over the data collection practices of these external services. -Inquiries can be submitted to wispar-app@protonmail.com \ No newline at end of file +## Contact +For any inquiries, please contact: support@wispar.app + +--- +Last Updated: March 2026 \ No newline at end of file diff --git a/README.md b/README.md index 45111f2d..8ad413c6 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,113 @@

-Wispar + Wispar

+

Stay up-to-date with academic journals and the latest research articles!

+

- + GitHub Workflow Status - - -Translation status - -DOI -
- - Get it on Google Play - - - Download on the App Store - -
- - Get it on F-Droid - -

-Buy Me a Coffee at ko-fi.com + + + Translation status + + + DOI + +

+ +

+ + Get it on Google Play + + + Download on the App Store + + + Get it on F-Droid + +
+ + + + + + + +

+ +

+ + Buy Me a Coffee at ko-fi.com +

--- ## Description -

-Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly searches scientific journals and articles using the Crossref and OpenAlex APIs. Stay updated on your preferred journals by following them and receive new article abstracts in your main feed. No account required. The integration of Unpaywall ensures convenient access to open-access articles, while EZproxy helps overcome subscription barriers. -

+ +Wispar is a user-friendly and privacy-friendly app for Android, iOS, Windows, MacOS and Linux that seamlessly searches scientific journals and articles using the Crossref and OpenAlex APIs. Stay updated on your preferred journals by following them and receive new article abstracts in your main feed. No account required. The integration of Unpaywall ensures convenient access to open-access articles, while EZproxy helps overcome subscription barriers. ## Features overview - + +- [x] Search and follow journals +- [x] Search for articles and save the queries for easy access later. You can even include them in your feed! +- [x] Sync your database across devices. You can also self-host the sync backend! +- [x] Download articles for offline reading +- [x] EZproxy and Unpaywall integration +- [x] Send articles to Zotero +- [x] Share articles +- [x] Scrape missing abstracts +- [x] Scrape graphical abstracts +- [x] Export/Import the local database +- [x] Notifications and background journals updates +- [x] Create custom feeds +- [x] Customizable swipe gestures +- [x] Translate title and abstracts (requires an AI API key) +- [x] Chat with your papers using AI + ## Translations -

-Wispar uses Weblate to manage translations. You can find the hosted instance at https://hosted.weblate.org/engage/wispar/ +Wispar uses Weblate to manage translations. You can find the hosted instance at [https://hosted.weblate.org/engage/wispar/](https://hosted.weblate.org/engage/wispar/) A huge thank you to Weblate for hosting the translations for free :heart:. Translation status: -

- -Translation status - + +[![Translation status](https://hosted.weblate.org/widget/wispar/multi-auto.svg)](https://hosted.weblate.org/engage/wispar/) ## Contribute -

-

- If you contribute to the project, feel free to add yourself to the .zenodo.json file to be credited! -

+ +There are many ways you can contribute to improving Wispar, and it's not just about writing code! + +* **Translations:** Help translate Wispar into your language using the [hosted Weblate instance](https://hosted.weblate.org/engage/wispar/). +* **Documentation:** Help me expand and finish the [official docs](https://wispar.app/docs/intro). It is currently a work in progress and any help is greatly appreciated! +* **Feedback:** Reporting bugs and suggesting features via [GitHub Issues](https://github.com/Scriptbash/Wispar/issues) is also an invaluable way to contribute! + +**If you contribute to the project, feel free to add yourself to the `.zenodo.json` file to be credited!** + ## Help -

-If you run into any issue while using Wispar, have a question or want to share your feedback, please open an issue here : https://github.com/Scriptbash/Wispar/issues -

+ +If you run into any issue while using Wispar, have a question or want to share your feedback, please [open an issue here](https://github.com/Scriptbash/Wispar/issues). + +If you have an issue with your Wispar Sync account, you can send an email at [support[at]wispar.app](mailto:support@wispar.app) and I will try to help as soon as possible. ## Credits - -## Screenshots +- Thank you [Sergio](https://github.com/reds2401) for the original app icon and [Lingling](https://github.com/Meigane) for the updated app icon! +- [Library Proxy URL Database](https://libproxy-db.org/) +- [Unpaywall](https://unpaywall.org/) +- [Crossref](https://www.crossref.org/) +- [OpenAlex](https://openalex.org/) +- [PocketBase](https://pocketbase.io/) +## Screenshots | ![Feed](android/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png) | ![Abstract](android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png) | ![Search](android/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png) | |---|---|---| diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 0c67376e..1f2c0ab4 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -1,5 +1,10 @@ - + + keychain-access-groups + + $(AppIdentifierPrefix)app.wispar.wispar + + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a6aa83c..05b652cc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -777,5 +777,95 @@ "downloadStarting": "Download starting.", "downloadFoundPdf": "Download found PDF", "downloadToApp": "Download in app", - "downloadToAppSubtitle": "Attempt to download and view PDF directly in Wispar for better integration." + "downloadToAppSubtitle": "Attempt to download and view PDF directly in Wispar for better integration.", + "loginToSyncDevices":"Login to sync across devices", + "@loginToSyncDevices":{}, + "syncLongDescription":"Wispar offers an optional sync service to keep your data consistent across devices. You can use Wispar Sync (see Privacy Policy) or self-host for full data control (see Documentation). You can also skip sync and continue.", + "@syncLongDescription":{}, + "documentation":"Documentation", + "@documentation":{}, + "cloudSync": "Cloud sync", + "@cloudSync":{}, + "selfHosted":"Self-Hosted", + "@selfHosted":{}, + "syncNow": "Sync now", + "@syncNow":{}, + "syncing":"Syncing...", + "@syncing":{}, + "lastSync":"Last sync: {time}", + "@lastSync":{}, + "backgroundSync": "Background syncing", + "@backgroundSync":{}, + "backgroundSyncDescription": "If enabled, syncing will occur automatically while the app is in use.", + "@backgroundSyncDescription":{}, + "authFailed":"Authentication failed: {error}", + "@authFailed":{}, + "accountAlreadyExists":"This account already exists.", + "@accountAlreadyExists":{}, + "pleaseEnterEmail":"Please enter your email.", + "@pleaseEnterEmail":{}, + "pleaseEnterValidEmail":"Please enter a valid email address.", + "@pleaseEnterValidEmail":{}, + "pleaseEnterPassword":"Please enter your password.", + "@pleaseEnterPassword":{}, + "passwordTooShort":"Password must be at least 8 characters.", + "@passwordTooShort":{}, + "checkdetailAndTryAgain":"Check your details and try again.", + "@checkdetailAndTryAgain":{}, + "invalidEmailOrPassword":"Invalid email or password.", + "@invalidEmailOrPassword":{}, + "cantConnectServer":"Could not connect to the server.", + "@cantConnectServer":{}, + "forgotPassword":"Forgot password?", + "@forgotPassword":{}, + "passwordResetSent": "Password reset email sent! Check your inbox and your spam folder.", + "@passwordResetSent":{}, + "deleteAccountFailed":"Failed to delete account: {error}", + "@deleteAccountFailed":{}, + "syncFailed":"Sync failed: {e}", + "@syncFailed":{}, + "syncSuccess":"Sync completed successfully!", + "@syncSuccess":{}, + "login":"Login", + "@login":{}, + "userIsLoggedIn":"You are now logged in! Your data will stay in sync.", + "@userIsLoggedIn":{}, + "logout":"Logout", + "@logout":{}, + "user":"User: {name}", + "@user":{}, + "syncServer":"Sync server: {address}", + "@syncServer":{}, + "serverUrl": "Server URL", + "@serverUrl":{}, + "checkEmail":"Check your email.", + "@checkEmail":{}, + "checkEmailDescription":"A verification link has been sent. Please verify your account before logging in.", + "@checkEmailDescription":{}, + "emailNotVerifiedError":"Please verify your email address before syncing.", + "@emailNotVerifiedError":{}, + "waitResendEmail":"Wait {cooldown}s to resend the email.", + "@waitResendEmail":{}, + "resendEmail":"Resend verification email", + "@resendEmail":{}, + "close":"Close", + "@close":{}, + "email":"Email", + "@email":{}, + "password":"Password", + "@password":{}, + "signUp":"Sign up", + "@signUp":{}, + "haveAnAccount":"Have an account? Login", + "@haveAnAccount":{}, + "needAnAccount":"Need an account? Sign up", + "@needAnAccount":{}, + "deleteCloudAccount":"Delete cloud account", + "@deleteCloudAccount":{}, + "deleteAccountQmark":"Permenantly delete account?", + "@deleteAccountQmark":{}, + "deleteAccountExplanation": "This will permanently delete your account and cloud data. Your local data on this device will remain.", + "@deleteAccountExplanation":{}, + "accountAndDataDeleted":"Account and cloud data deleted", + "@accountAndDataDeleted":{} } diff --git a/lib/main.dart b/lib/main.dart index 00dffb0a..b9cffd34 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,11 +14,13 @@ import 'package:wispar/screens/downloads_screen.dart'; import 'package:google_nav_bar/google_nav_bar.dart'; import 'package:wispar/services/background_service.dart'; import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/services/pocketbase_service.dart'; import 'package:background_fetch/background_fetch.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:wispar/services/sync_service.dart'; import 'package:wispar/webview_env.dart'; import 'dart:io' show Platform; @@ -45,6 +47,7 @@ void main() async { databaseFactory = databaseFactoryFfi; } LogsService(); + await PocketBaseService().init(); if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { await windowManager.ensureInitialized(); WindowOptions windowOptions = const WindowOptions( @@ -78,19 +81,24 @@ void main() async { class Wispar extends StatefulWidget { static const title = 'Wispar'; - const Wispar({Key? key}) : super(key: key); + const Wispar({super.key}); @override - _WisparState createState() => _WisparState(); + WisparState createState() => WisparState(); } -class _WisparState extends State { +class WisparState extends State { bool _hasSeenIntro = false; + final pbService = PocketBaseService(); + final syncManager = SyncManager(); @override void initState() { super.initState(); _checkIntroPreference(); + WidgetsBinding.instance.addPostFrameCallback((_) { + syncManager.triggerBackgroundSync(); + }); } // Load the intro preference @@ -173,14 +181,13 @@ class _WisparState extends State { class HomeScreenNavigator extends StatefulWidget { final bool skipToSearch; - const HomeScreenNavigator({Key? key, this.skipToSearch = false}) - : super(key: key); + const HomeScreenNavigator({super.key, this.skipToSearch = false}); @override - _HomeScreenNavigatorState createState() => _HomeScreenNavigatorState(); + HomeScreenNavigatorState createState() => HomeScreenNavigatorState(); } -class _HomeScreenNavigatorState extends State { +class HomeScreenNavigatorState extends State { var _currentIndex = 0; @override diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a6d830df..b2f27ef8 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import 'package:wispar/services/database_helper.dart'; import 'package:wispar/services/feed_service.dart'; import 'package:wispar/services/abstract_helper.dart'; import 'package:wispar/models/feed_filter_entity.dart'; +import 'package:wispar/services/sync_service.dart'; import 'package:wispar/widgets/publication_card/publication_card.dart'; import 'package:wispar/screens/publication_card_settings_screen.dart'; import 'package:wispar/widgets/sort_dialog.dart'; @@ -46,6 +47,7 @@ class HomeScreenState extends State { List _activeFeed = []; final FeedService _feedService = FeedService(); + final syncManager = SyncManager(); List> savedQueries = []; bool _feedLoaded = false; // Needed to avoid conflicts wih onAbstractChanged @@ -916,6 +918,7 @@ class HomeScreenState extends State { .primary), onPressed: () async { await db.deleteFeedFilter(filter.id); + syncManager.triggerBackgroundSync(); Navigator.pop(context); // Reset to Home feed after deletion diff --git a/lib/screens/institutional_settings_screen.dart b/lib/screens/institutional_settings_screen.dart index 12dd19f6..c2c0c15c 100644 --- a/lib/screens/institutional_settings_screen.dart +++ b/lib/screens/institutional_settings_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../generated_l10n/app_localizations.dart'; -import './institutions_screen.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/sync_service.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/screens/institutions_screen.dart'; +import 'package:wispar/services/database_helper.dart'; class InstitutionalSettingsScreen extends StatefulWidget { const InstitutionalSettingsScreen({super.key}); @@ -15,6 +16,7 @@ class InstitutionalSettingsScreen extends StatefulWidget { class _InstitutionalSettingsScreenState extends State { final dbHelper = DatabaseHelper(); + final syncManager = SyncManager(); List> knownUrls = []; @override @@ -25,7 +27,10 @@ class _InstitutionalSettingsScreenState Future _loadKnownUrls() async { final db = await dbHelper.database; - final urls = await db.query('knownUrls'); + final urls = await db.query( + 'knownUrls', + where: 'is_deleted = 0', + ); setState(() { knownUrls = urls; }); @@ -126,12 +131,14 @@ class _InstitutionalSettingsScreenState proxySuccess: updated['proxySuccess'], ); _loadKnownUrls(); + syncManager.triggerBackgroundSync(); } } Future _deleteKnownUrl(int id) async { await dbHelper.deleteKnownUrl(id); _loadKnownUrls(); + syncManager.triggerBackgroundSync(); } @override @@ -303,6 +310,7 @@ class _InstitutionalSettingsScreenState newEntry['url'].isNotEmpty) { await dbHelper.insertKnownUrl(newEntry['url'], newEntry['proxySuccess']); _loadKnownUrls(); + syncManager.triggerBackgroundSync(); } } } diff --git a/lib/screens/introduction_screen.dart b/lib/screens/introduction_screen.dart index 0f005662..779be25b 100644 --- a/lib/screens/introduction_screen.dart +++ b/lib/screens/introduction_screen.dart @@ -1,21 +1,28 @@ import 'package:flutter/material.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../generated_l10n/app_localizations.dart'; -import './institutions_screen.dart'; -import './zotero_settings_screen.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/screens/institutions_screen.dart'; +import 'package:wispar/screens/zotero_settings_screen.dart'; +import 'package:wispar/services/sync_service.dart'; +import 'package:wispar/widgets/sync_auth_form.dart'; +import 'package:wispar/services/pocketbase_service.dart'; +import 'package:wispar/services/logs_helper.dart'; class IntroScreen extends StatefulWidget { final VoidCallback onDone; - const IntroScreen({Key? key, required this.onDone}) : super(key: key); + const IntroScreen({super.key, required this.onDone}); @override - _IntroScreenState createState() => _IntroScreenState(); + IntroScreenState createState() => IntroScreenState(); } -class _IntroScreenState extends State { +class IntroScreenState extends State { String institutionName = 'No institution'; + final pbService = PocketBaseService(); + final syncManager = SyncManager(); + final logger = LogsService().logger; @override void initState() { @@ -88,6 +95,55 @@ class _IntroScreenState extends State { bodyFlex: 1, ), ), + PageViewModel( + titleWidget: const SizedBox.shrink(), + bodyWidget: Column( + children: [ + const SizedBox(height: 8), + Icon( + pbService.isAuthenticated + ? Icons.cloud_done + : Icons.cloud_sync, + size: 100, + color: Colors.deepPurpleAccent), + const SizedBox(height: 20), + Text( + "Wispar Sync", + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.deepPurpleAccent), + ), + const SizedBox(height: 12), + if (!pbService.isAuthenticated) ...[ + Text( + AppLocalizations.of(context)!.syncLongDescription, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 20), + SyncAuthForm( + onLoginSuccess: () async { + setState(() => ()); + try { + syncManager.sync(isFullSync: true); + } catch (e, stackTrace) { + logger.severe("The initial sync failed.", e, stackTrace); + } + }, + ), + ] else ...[ + const SizedBox(height: 20), + Text( + AppLocalizations.of(context)!.userIsLoggedIn, + style: TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + ], + ], + ), + decoration: const PageDecoration(bodyFlex: 2), + ), PageViewModel( titleWidget: const SizedBox.shrink(), bodyWidget: SingleChildScrollView( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9beef963..d3338431 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ import 'package:wispar/screens/display_settings_screen.dart'; import 'package:wispar/screens/publication_card_settings_screen.dart'; import 'package:wispar/screens/logs_screen.dart'; import 'package:wispar/screens/institutional_settings_screen.dart'; +import 'package:wispar/screens/sync_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -98,6 +99,14 @@ class SettingsScreenState extends State { MaterialPageRoute( builder: (context) => const PublicationCardSettingsScreen())), ), + _buildTile( + icon: Icons.cloud_sync_outlined, + label: AppLocalizations.of(context)!.cloudSync, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SyncSettingsScreen())), + ), _buildTile( icon: Icons.api_outlined, label: AppLocalizations.of(context)!.apiSettings, diff --git a/lib/screens/sync_screen.dart b/lib/screens/sync_screen.dart new file mode 100644 index 00000000..467e5437 --- /dev/null +++ b/lib/screens/sync_screen.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:wispar/services/pocketbase_service.dart'; +import 'package:wispar/services/sync_service.dart'; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/widgets/sync_auth_form.dart'; + +class SyncSettingsScreen extends StatefulWidget { + const SyncSettingsScreen({super.key}); + + @override + State createState() => _SyncSettingsScreenState(); +} + +class _SyncSettingsScreenState extends State { + final pbService = PocketBaseService(); + final syncManager = SyncManager(); + final DatabaseHelper dbHelper = DatabaseHelper(); + final logger = LogsService().logger; + bool _isSyncing = false; + DateTime? _lastSyncDate; + + @override + void initState() { + super.initState(); + _loadLastSync(); + } + + Future _handleDeleteAccount() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteAccountQmark), + content: Text(AppLocalizations.of(context)!.deleteAccountExplanation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel)), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(AppLocalizations.of(context)!.deleteCloudAccount, + style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() => _isSyncing = true); + try { + await pbService.deleteAccount(); + setState(() { + _isSyncing = false; + _lastSyncDate = null; + }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context)!.accountAndDataDeleted)), + ); + logger.info('A cloud account was deleted.'); + } catch (e, stackTrace) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.deleteAccountFailed(e)), + )); + logger.severe( + 'Failed to delete account.', + e, + stackTrace, + ); + } finally { + setState(() => _isSyncing = false); + } + } + } + + Future _loadLastSync() async { + final rawDate = await dbHelper.getLastSync(); + if (rawDate != null) { + if (mounted) { + setState(() { + _lastSyncDate = DateTime.parse(rawDate).toLocal(); + }); + } + } + } + + Future _runSync() async { + setState(() => _isSyncing = true); + try { + await syncManager.sync(isFullSync: true); + await _loadLastSync(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.syncSuccess)), + ); + logger.info('Cloud syncing was successful.'); + } catch (e, stackTrace) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.syncFailed(e))), + ); + logger.severe( + 'Sync encountered an error.', + e, + stackTrace, + ); + } finally { + setState(() => _isSyncing = false); + } + } + + Future _isBackgroundSyncEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('background_sync_enabled') ?? true; + } + + Future _toggleBackgroundSync(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('background_sync_enabled', value); + setState(() {}); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.cloudSync)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + AppLocalizations.of(context)!.loginToSyncDevices, + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + SizedBox( + height: 16, + ), + if (!pbService.isAuthenticated) ...[ + SyncAuthForm( + onLoginSuccess: () { + setState(() {}); + _runSync(); + }, + ), + ] else ...[ + Card( + elevation: 3, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.primary), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.cloud_done, + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + pbService.client.authStore.record + ?.get("email") ?? + "Unknown", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ), + ], + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + AppLocalizations.of(context)! + .syncServer(pbService.baseURL), + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .textTheme + .bodySmall + ?.color, + ), + ), + ), + if (_lastSyncDate != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + AppLocalizations.of(context)!.lastSync( + DateFormat.yMMMd( + Localizations.localeOf(context) + .languageCode) + .add_jm() + .format(_lastSyncDate!), + ), + style: const TextStyle( + fontSize: 11, color: Colors.grey), + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _isSyncing ? null : _runSync, + icon: _isSyncing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.sync), + label: Text(_isSyncing + ? AppLocalizations.of(context)!.syncing + : AppLocalizations.of(context)!.syncNow), + ), + FutureBuilder( + future: _isBackgroundSyncEnabled(), + builder: (context, snapshot) { + final bool isEnabled = snapshot.data ?? true; + return SwitchListTile( + title: Text(AppLocalizations.of(context)!.backgroundSync), + subtitle: Text( + AppLocalizations.of(context)!.backgroundSyncDescription), + value: isEnabled, + onChanged: _isSyncing ? null : _toggleBackgroundSync, + contentPadding: EdgeInsets.zero, + ); + }, + ), + const SizedBox(height: 48), + OutlinedButton( + onPressed: () { + pbService.client.authStore.clear(); + setState(() { + _lastSyncDate = null; + _isSyncing = false; + }); + }, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text(AppLocalizations.of(context)!.logout), + ), + const Divider(), + TextButton.icon( + onPressed: _isSyncing ? null : _handleDeleteAccount, + icon: const Icon(Icons.delete_forever, color: Colors.red), + label: Text(AppLocalizations.of(context)!.deleteCloudAccount, + style: TextStyle(color: Colors.red)), + ), + ], + ], + ), + ); + } +} diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 1e176c9f..03a718c1 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -13,6 +13,7 @@ import 'package:wispar/services/logs_helper.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; class DatabaseHelper { static const platform = MethodChannel('app.wispar.wispar/database_access'); @@ -73,11 +74,13 @@ class DatabaseHelper { await db.execute(''' CREATE TABLE journals ( journal_id INTEGER PRIMARY KEY AUTOINCREMENT, - issn TEXT, title TEXT, publisher TEXT, dateFollowed TEXT, - lastUpdated TEXT + lastUpdated TEXT, + sync_id TEXT UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0 ) '''); // Create the journal_issns table @@ -85,6 +88,9 @@ class DatabaseHelper { CREATE TABLE journal_issns ( issn TEXT PRIMARY KEY, journal_id INTEGER, + sync_id TEXT UNIQUE UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0, FOREIGN KEY (journal_id) REFERENCES journals(journal_id) ) '''); @@ -112,6 +118,9 @@ class DatabaseHelper { query_id INTEGER, graphAbstractPath, journal_id, + sync_id TEXT UNIQUE UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0, FOREIGN KEY (journal_id) REFERENCES journals(journal_id) ) '''); @@ -125,7 +134,10 @@ class DatabaseHelper { dateSaved TEXT, includeInFeed INTEGER, lastFetched TEXT, - queryProvider TEXT + queryProvider TEXT, + sync_id TEXT UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0 ) '''); @@ -137,7 +149,13 @@ class DatabaseHelper { includedKeywords TEXT, excludedKeywords TEXT, journals TEXT, - dateCreated TEXT DEFAULT CURRENT_TIMESTAMP + date_mode TEXT, + date_after TEXT, + date_before TEXT, + dateCreated TEXT DEFAULT CURRENT_TIMESTAMP, + sync_id TEXT UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0 ) '''); @@ -145,7 +163,10 @@ class DatabaseHelper { CREATE TABLE knownUrls ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT, - proxySuccess INTEGER + proxySuccess INTEGER, + sync_id TEXT UNIQUE NOT NULL, + updated_at TEXT, + is_deleted INTEGER DEFAULT 0 ) '''); }, onUpgrade: (db, oldVersion, newVersion) async { @@ -279,6 +300,131 @@ class DatabaseHelper { await db.execute(''' ALTER TABLE feed_filters ADD COLUMN date_before TEXT; '''); + await db.execute('ALTER TABLE journals ADD COLUMN sync_id TEXT;'); + await db.execute('ALTER TABLE journals ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE journals ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + await db.execute('ALTER TABLE journal_issns ADD COLUMN sync_id TEXT;'); + await db + .execute('ALTER TABLE journal_issns ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE journal_issns ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + await db.execute('ALTER TABLE articles ADD COLUMN sync_id TEXT;'); + await db.execute('ALTER TABLE articles ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE articles ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + await db.execute('ALTER TABLE savedQueries ADD COLUMN sync_id TEXT;'); + await db + .execute('ALTER TABLE savedQueries ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE savedQueries ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + await db.execute('ALTER TABLE feed_filters ADD COLUMN sync_id TEXT;'); + await db + .execute('ALTER TABLE feed_filters ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE feed_filters ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + await db.execute('ALTER TABLE knownUrls ADD COLUMN sync_id TEXT;'); + await db.execute('ALTER TABLE knownUrls ADD COLUMN updated_at TEXT;'); + await db.execute( + 'ALTER TABLE knownUrls ADD COLUMN is_deleted INTEGER DEFAULT 0;'); + + // Initialize sync_ids for existing rows + Future initSyncIds(String table, String pk) async { + final rows = await db.query(table); + for (final row in rows) { + await db.update( + table, + { + 'sync_id': Uuid().v7(), + 'updated_at': DateTime.fromMillisecondsSinceEpoch(0) + .toUtc() + .toIso8601String(), + }, + where: '$pk = ?', + whereArgs: [row[pk]]); + } + } + + await initSyncIds('journals', 'journal_id'); + await initSyncIds('journal_issns', 'issn'); + await initSyncIds('articles', 'article_id'); + await initSyncIds('savedQueries', 'query_id'); + await initSyncIds('feed_filters', 'id'); + await initSyncIds('knownUrls', 'id'); + + try { + await db.execute("ALTER TABLE journals DROP COLUMN issn"); + } catch (e) { + logger.info('Unable to drop the issn column in the journals table'); + } + await db.delete('journals', where: 'title IS NULL OR TRIM(title) = ""'); + int deletedInvalidIssns = await db.delete('journal_issns', + where: 'issn IS NULL OR TRIM(issn) = ""'); + + logger.info( + "Deleted $deletedInvalidIssns invalid ISSN records with empty keys."); + + // Merge duplicated journals + final List> allJournals = + await db.query('journals'); + final Set processedIds = {}; + + for (var j in allJournals) { + int currentId = j['journal_id'] as int; + if (processedIds.contains(currentId)) continue; + + String currentTitle = + (j['title'] ?? "").toString().toLowerCase().trim(); + String currentPub = + (j['publisher'] ?? "").toString().toLowerCase().trim(); + if (currentTitle.isEmpty) continue; + + final List> siblings = await db.query( + 'journals', + where: 'LOWER(TRIM(title)) = ? AND journal_id != ?', + whereArgs: [currentTitle, currentId], + ); + + for (var sibling in siblings) { + int sibId = sibling['journal_id'] as int; + if (processedIds.contains(sibId)) continue; + + String sibPub = + (sibling['publisher'] ?? "").toString().toLowerCase().trim(); + + bool isMatch = false; + if (currentPub.isEmpty || sibPub.isEmpty) { + isMatch = true; + } else if (currentPub.contains(sibPub) || + sibPub.contains(currentPub)) { + isMatch = true; + } + + if (isMatch) { + bool currentIsBetter = currentPub.length >= sibPub.length || + j['dateFollowed'] != null; + + int keepId = currentIsBetter ? currentId : sibId; + int deleteId = currentIsBetter ? sibId : currentId; + + await db.update('journal_issns', {'journal_id': keepId}, + where: 'journal_id = ?', whereArgs: [deleteId]); + await db.update('articles', {'journal_id': keepId}, + where: 'journal_id = ?', whereArgs: [deleteId]); + + // Delete duplicate + await db.delete('journals', + where: 'journal_id = ?', whereArgs: [deleteId]); + processedIds.add(deleteId); + + logger.info( + "Fuzzy Merged '$currentTitle': ID $deleteId into $keepId"); + + if (keepId == sibId) break; + } + } + processedIds.add(currentId); + } } }); } @@ -295,64 +441,114 @@ class DatabaseHelper { Future insertJournal(Journal journal) async { final db = await database; - final existingIssn = await db.query( - 'journal_issns', - where: 'issn IN (${List.filled(journal.issn.length, '?').join(',')})', - whereArgs: journal.issn, + int? journalId = await getOrCreateJournalId( + issns: journal.issn, + title: journal.title, + publisher: journal.publisher, ); - if (existingIssn.isNotEmpty) { - final int journalId = existingIssn.first['journal_id'] as int; - - final journalMap = await db.query( + if (journalId != null) { + await db.update( 'journals', + { + 'dateFollowed': journal.dateFollowed ?? + DateTime.now().toIso8601String().substring(0, 10), + 'title': journal.title, + 'publisher': journal.publisher, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'journal_id = ?', whereArgs: [journalId], ); + } + } - if (journalMap.isNotEmpty && journalMap.first['dateFollowed'] == null) { - await db.update( - 'journals', - { - 'dateFollowed': DateTime.now().toIso8601String().substring(0, 10), - 'title': journal.title, - 'publisher': journal.publisher, - }, - where: 'journal_id = ?', - whereArgs: [journalId], - ); + Future getOrCreateJournalId({ + required List issns, + required String? title, + String? publisher, + }) async { + if ((title == null || title.trim().isEmpty) && issns.isEmpty) { + return null; // The journal has no ISSN and no title, skip it + } - await db.delete( - 'journal_issns', - where: 'journal_id = ?', - whereArgs: [journalId], - ); + final db = await database; + + // Try to match by issns + if (issns.isNotEmpty) { + final existingIssn = await db.query( + 'journal_issns', + where: 'issn IN (${List.filled(issns.length, '?').join(',')})', + whereArgs: issns, + ); + if (existingIssn.isNotEmpty) { + return existingIssn.first['journal_id'] as int; + } + } + + // Try to match by title and publisher + if (title != null && title.trim().isNotEmpty) { + final List> titleMatches = await db.query( + 'journals', + where: 'LOWER(TRIM(title)) = ?', + whereArgs: [title.toLowerCase().trim()], + ); + + for (var match in titleMatches) { + String dbPub = + (match['publisher'] ?? "").toString().toLowerCase().trim(); + String newPub = (publisher ?? "").toLowerCase().trim(); + + if (dbPub.isEmpty || + newPub.isEmpty || + dbPub.contains(newPub) || + newPub.contains(dbPub)) { + int journalId = match['journal_id']; + + // Update the issns if new ones are available form the duplicate + for (final issn in issns) { + await db.insert( + 'journal_issns', + { + 'issn': issn, + 'journal_id': journalId, + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.ignore); + } + return journalId; + } + } + } + // The journal doesn't exist so insert it if it has a title + if (title != null && title.trim().isNotEmpty) { + final journalId = await db.insert('journals', { + 'title': title, + 'publisher': publisher, + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); - for (final issn in journal.issn) { + for (final issn in issns) { + // Insert the journal's issns if they are not missing + if (issn.trim().isNotEmpty) { await db.insert( 'journal_issns', { - 'issn': issn, + 'issn': issn.trim(), 'journal_id': journalId, + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), }, - conflictAlgorithm: ConflictAlgorithm.replace, + conflictAlgorithm: ConflictAlgorithm.ignore, ); } } - } else { - final journalId = await db.insert('journals', journal.toMap()); - - for (final issn in journal.issn) { - await db.insert( - 'journal_issns', - { - 'issn': issn, - 'journal_id': journalId, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } + return journalId; } + + return null; } Future> getFollowedJournals() async { @@ -452,27 +648,36 @@ class DatabaseHelper { } Future removeJournal(List issns) async { - final db = await database; - int? journalId = await getJournalIdByIssns(issns); - if (journalId != null) { - await db.update( - 'articles', - {'dateCached': null}, - where: 'journal_id = ?', - whereArgs: [journalId], - ); - - await db.update( - 'journals', - {'dateFollowed': null, 'lastUpdated': null}, - where: 'journal_id = ?', - whereArgs: [journalId], - ); + await removeJournalById(journalId); } } + Future removeJournalById(int journalId) async { + final db = await database; + + await db.update( + 'articles', + { + 'dateCached': null, + }, + where: 'journal_id = ?', + whereArgs: [journalId], + ); + + await db.update( + 'journals', + { + 'dateFollowed': null, + 'lastUpdated': null, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + where: 'journal_id = ?', + whereArgs: [journalId], + ); + } + Future isJournalFollowed(int journalId) async { final db = await database; @@ -491,7 +696,9 @@ class DatabaseHelper { final db = await database; await db.update( 'journals', - {'lastUpdated': DateTime.now().toIso8601String()}, + { + 'lastUpdated': DateTime.now().toIso8601String(), + }, where: 'journal_id = ?', whereArgs: [journalId], ); @@ -520,6 +727,7 @@ class DatabaseHelper { if (existingArticle.isNotEmpty) { // Article already exists, update the timestamp based on parameters final Map updateData = {}; + updateData['updated_at'] = DateTime.now().toUtc().toIso8601String(); if (isLiked && existingArticle[0]['dateLiked'] == null) { updateData['dateLiked'] = @@ -545,38 +753,20 @@ class DatabaseHelper { ); } } else { - int? journalId = await getJournalIdByIssns(publicationCard.issn); - if (journalId == null) { - // Journal not found, insert it - final Map journalData = { - 'title': publicationCard.journalTitle, - 'publisher': publicationCard.publisher, - }; - - journalId = await db.insert('journals', journalData); - - // Insert the ISSNs into the journal_issns table - for (final issn in publicationCard.issn) { - await db.insert( - 'journal_issns', - { - 'issn': issn, - 'journal_id': journalId, - }, - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } - } + int? journalId = await getOrCreateJournalId( + issns: publicationCard.issn, + title: publicationCard.journalTitle, + publisher: publicationCard.publisher, + ); - // Insert the article + // Insert the article, but store a journal_id null if it had no journal await db.insert('articles', { 'doi': publicationCard.doi, 'title': publicationCard.title, 'abstract': publicationCard.abstract, 'publishedDate': publicationCard.publishedDate?.toIso8601String(), - 'authors': jsonEncode(publicationCard.authors - .map((author) => author.toJson()) - .toList()), // Serialize authors to JSON + 'authors': jsonEncode( + publicationCard.authors.map((author) => author.toJson()).toList()), 'url': publicationCard.url, 'license': publicationCard.license, 'licenseName': publicationCard.licenseName, @@ -589,39 +779,49 @@ class DatabaseHelper { 'dateCached': isCached ? DateTime.now().toIso8601String() : null, 'isSavedQuery': isSavedQuery ? 1 : 0, 'query_id': queryId, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'sync_id': const Uuid().v7(), 'journal_id': journalId, }); } } Future> getFavoriteArticles() async { - final Database db = await DatabaseHelper().database; + final Database db = await database; final List> maps = await db.rawQuery(''' SELECT articles.*, journals.title AS journalTitle, - GROUP_CONCAT(journal_issns.issn) AS issns - FROM articles - LEFT JOIN journals ON articles.journal_id = journals.journal_id - LEFT JOIN journal_issns ON journals.journal_id = journal_issns.journal_id - WHERE articles.dateLiked IS NOT NULL - GROUP BY articles.article_id + GROUP_CONCAT(journal_issns.issn) AS issns + FROM articles + LEFT JOIN journals ON articles.journal_id = journals.journal_id + LEFT JOIN journal_issns ON journals.journal_id = journal_issns.journal_id + WHERE articles.dateLiked IS NOT NULL + GROUP BY articles.article_id '''); return List.generate(maps.length, (i) { - List issns = (maps[i]['issns'] as String?)?.split(',') ?? []; + String? rawIssns = maps[i]['issns'] as String?; + List issns = rawIssns != null ? rawIssns.split(',') : []; + + DateTime? pubDate; + if (maps[i]['publishedDate'] != null) { + pubDate = DateTime.tryParse(maps[i]['publishedDate']); + } return PublicationCard( doi: maps[i]['doi'], title: maps[i]['title'], issn: issns, abstract: maps[i]['abstract'], - publishedDate: DateTime.parse(maps[i]['publishedDate']), - authors: List.from( - (jsonDecode(maps[i]['authors']) as List) - .map((authorJson) => PublicationAuthor.fromJson(authorJson)), - ), + publishedDate: pubDate, + authors: maps[i]['authors'] != null + ? List.from( + (jsonDecode(maps[i]['authors']) as List).map( + (authorJson) => PublicationAuthor.fromJson(authorJson)), + ) + : [], dateLiked: maps[i]['dateLiked'], - journalTitle: maps[i]['journalTitle'], + journalTitle: maps[i]['journalTitle'] ?? '', url: maps[i]['url'], license: maps[i]['license'], licenseName: maps[i]['licenseName'], @@ -640,7 +840,10 @@ class DatabaseHelper { await db.update( 'articles', - {'dateLiked': null}, + { + 'dateLiked': null, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'doi = ?', whereArgs: [doi], ); @@ -657,49 +860,37 @@ class DatabaseHelper { Future insertCachedPublication(PublicationCard publicationCard) async { final db = await database; + + int? journalId = await getOrCreateJournalId( + issns: publicationCard.issn, + title: publicationCard.journalTitle, + publisher: publicationCard.publisher, + ); + final List> publicationMaps = await db.query( 'articles', - columns: ['article_id', 'dateCached'], where: 'doi = ?', whereArgs: [publicationCard.doi], ); if (publicationMaps.isNotEmpty) { - // Publication found, retrieve its ID - final int articleId = publicationMaps.first['article_id']; - - // If the publication wasn't cached before, update the dateCached - if (publicationMaps.first['dateCached'] == null) { - final int? journalId = await getJournalIdByIssns(publicationCard.issn); - - if (journalId == null) { - throw Exception('No matching journal found for the given ISSNs.'); - } - - await db.update( - 'articles', - { - 'dateCached': DateTime.now().toIso8601String(), - 'title': publicationCard.title, - 'abstract': publicationCard.abstract, - 'journal_id': journalId, - 'publishedDate': publicationCard.publishedDate?.toIso8601String(), - 'authors': jsonEncode( - publicationCard.authors.map((author) => author.toJson()).toList(), - ), - }, - where: 'article_id = ?', - whereArgs: [articleId], - ); - } + await db.update( + 'articles', + { + 'dateCached': DateTime.now().toIso8601String(), + 'title': publicationCard.title, + 'abstract': publicationCard.abstract, + 'journal_id': journalId, + 'publishedDate': publicationCard.publishedDate?.toIso8601String(), + 'authors': jsonEncode( + publicationCard.authors.map((author) => author.toJson()).toList(), + ), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + where: 'doi = ?', + whereArgs: [publicationCard.doi], + ); } else { - // Publication not found, insert it - final int? journalId = await getJournalIdByIssns(publicationCard.issn); - - if (journalId == null) { - throw Exception('No matching journal found for the given ISSNs.'); - } - await db.insert('articles', { 'doi': publicationCard.doi, 'title': publicationCard.title, @@ -710,6 +901,8 @@ class DatabaseHelper { publicationCard.authors.map((author) => author.toJson()).toList(), ), 'dateCached': DateTime.now().toIso8601String(), + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), }); } } @@ -790,7 +983,10 @@ class DatabaseHelper { final db = await database; await db.update( 'articles', - {'isHidden': 1}, + { + 'isHidden': 1, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'doi = ?', whereArgs: [doi], ); @@ -800,7 +996,10 @@ class DatabaseHelper { final db = await database; await db.update( 'articles', - {'isHidden': 0}, + { + 'isHidden': 0, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'doi = ?', whereArgs: [doi], ); @@ -1007,6 +1206,8 @@ class DatabaseHelper { 'dateSaved': dateSaved, 'includeInFeed': 0, 'queryProvider': provider, + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), }, ); } @@ -1059,7 +1260,10 @@ class DatabaseHelper { final db = await database; await db.update( 'savedQueries', - {'includeInFeed': includeInFeed ? 1 : 0}, + { + 'includeInFeed': includeInFeed ? 1 : 0, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'query_id = ?', whereArgs: [id], ); @@ -1067,7 +1271,10 @@ class DatabaseHelper { if (!includeInFeed) { await db.update( 'savedQueries', - {'lastFetched': null}, + { + 'lastFetched': null, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'query_id = ?', whereArgs: [id], ); @@ -1298,12 +1505,18 @@ class DatabaseHelper { 'date_mode': dateMode, 'date_after': dateAfter, 'date_before': dateBefore, + 'sync_id': const Uuid().v7(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), }); } Future>> getFeedFilters() async { final db = await database; - return await db.query('feed_filters', orderBy: 'dateCreated DESC'); + return await db.query( + 'feed_filters', + orderBy: 'dateCreated DESC', + where: 'is_deleted = 0', + ); } Future> getParsedFeedFilters() async { @@ -1345,6 +1558,7 @@ class DatabaseHelper { 'date_mode': dateMode, 'date_after': dateAfter, 'date_before': dateBefore, + 'updated_at': DateTime.now().toUtc().toIso8601String(), }, where: 'id = ?', whereArgs: [id], @@ -1353,24 +1567,60 @@ class DatabaseHelper { Future deleteFeedFilter(int id) async { final db = await database; - await db.delete('feed_filters', where: 'id = ?', whereArgs: [id]); + await db.update( + 'feed_filters', + { + 'is_deleted': 1, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); } Future insertKnownUrl(String url, int proxySuccess) async { final db = await database; + + final existing = await db.query( + 'knownUrls', + where: 'url = ?', + whereArgs: [url], + limit: 1, + ); + + if (existing.isNotEmpty) { + final id = existing.first['id'] as int; + return await db.update( + 'knownUrls', + { + 'proxySuccess': proxySuccess, + 'is_deleted': 0, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); + } + return await db.insert( 'knownUrls', { 'url': url, 'proxySuccess': proxySuccess, + 'sync_id': const Uuid().v7(), + 'is_deleted': 0, + 'updated_at': DateTime.now().toUtc().toIso8601String(), }, - conflictAlgorithm: ConflictAlgorithm.replace, ); } Future updateKnownUrl(int id, {String? url, int? proxySuccess}) async { final db = await database; - final updateData = {}; + final updateData = { + 'is_deleted': 0, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }; + if (url != null) updateData['url'] = url; if (proxySuccess != null) updateData['proxySuccess'] = proxySuccess; @@ -1384,8 +1634,12 @@ class DatabaseHelper { Future deleteKnownUrl(int id) async { final db = await database; - return await db.delete( + return await db.update( 'knownUrls', + { + 'is_deleted': 1, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, where: 'id = ?', whereArgs: [id], ); @@ -1395,9 +1649,67 @@ class DatabaseHelper { final db = await database; final results = await db.query( 'knownUrls', - where: 'url = ?', + where: 'url = ? AND is_deleted = 0', whereArgs: [url], ); return results.isNotEmpty ? results.first : null; } + + Future getLastSync() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('last_sync'); + } + + Future setLastSync(String timestamp) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_sync', timestamp); + } + + Future syncJournalFromCloud(Map data) async { + final db = await database; + final syncId = data['sync_id']; + + final existingJournals = await db.query( + 'journals', + where: 'sync_id = ?', + whereArgs: [syncId], + limit: 1, + ); + + if (existingJournals.isEmpty) { + await db.insert('journals', data); + } else { + await db.update( + 'journals', + data, + where: 'sync_id = ?', + whereArgs: [syncId], + ); + } + } + + Future syncJournalIssnFromCloud(Map data) async { + final db = await database; + + final syncId = data['sync_id']; + + final existingIssns = await db.query( + 'journal_issns', + where: 'sync_id = ?', + whereArgs: [syncId], + limit: 1, + ); + + if (existingIssns.isEmpty) { + await db.insert('journal_issns', data, + conflictAlgorithm: ConflictAlgorithm.replace); + } else { + await db.update( + 'journal_issns', + data, + where: 'sync_id = ?', + whereArgs: [syncId], + ); + } + } } diff --git a/lib/services/pocketbase_service.dart b/lib/services/pocketbase_service.dart new file mode 100644 index 00000000..787529e3 --- /dev/null +++ b/lib/services/pocketbase_service.dart @@ -0,0 +1,95 @@ +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class PocketBaseService { + PocketBaseService._internal(); + static final PocketBaseService _instance = PocketBaseService._internal(); + factory PocketBaseService() => _instance; + + bool get isAuthenticated => _client?.authStore.isValid ?? false; + bool get isVerified => + _client?.authStore.record?.getBoolValue('verified') ?? false; + String get baseURL => _client?.baseURL ?? 'https://sync.wispar.app'; + + PocketBase? _client; + + PocketBase get client { + if (_client == null) { + throw StateError("PocketBaseService not initialized. Call init() first."); + } + return _client!; + } + + bool _isInitialized = false; + + Future init() async { + if (_isInitialized) return; + final secureStorage = const FlutterSecureStorage(); + final prefs = await SharedPreferences.getInstance(); + final store = AsyncAuthStore( + save: (String data) async => + await secureStorage.write(key: 'pb_auth', value: data), + initial: await secureStorage.read(key: 'pb_auth'), + clear: () async => await secureStorage.delete(key: 'pb_auth'), + ); + + final savedUrl = + prefs.getString('pb_custom_url') ?? 'https://sync.wispar.app'; + + _client = PocketBase(savedUrl, authStore: store); + _isInitialized = true; + } + + Future register(String email, String password) async { + try { + await client.collection('users').create(body: { + 'email': email, + 'password': password, + 'passwordConfirm': password, + 'emailVisibility': false, + }); + await client.collection('users').requestVerification(email); + return await client.collection('users').authWithPassword(email, password); + } catch (e) { + rethrow; + } + } + + Future resendVerification(String email) async { + try { + await client.collection('users').requestVerification(email); + } catch (e) { + rethrow; + } + } + + Future deleteAccount() async { + try { + final userId = client.authStore.record?.id; + if (userId == null) return; + + await client.collection('users').delete(userId); + + client.authStore.clear(); + } catch (e) { + rethrow; + } + } + + Future requestPasswordReset(String email) async { + try { + await client.collection('users').requestPasswordReset(email); + } catch (e) { + rethrow; + } + } + + Future updateCustomUrl(String newUrl) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pb_custom_url', newUrl); + + _isInitialized = false; + await init(); + } +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 00000000..a770ea17 --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,904 @@ +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wispar/services/pocketbase_service.dart'; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'dart:async'; + +class SyncManager { + final PocketBaseService pbService = PocketBaseService(); + final DatabaseHelper dbHelper = DatabaseHelper(); + final logger = LogsService().logger; + Timer? _debounce; + + // Little function to compare the slightly rounded time, + //otherwise patches are sent every sync when unnecessary + bool _isNewer(String localStr, String pbStr) { + final local = DateTime.tryParse(localStr)?.toUtc(); + final pb = DateTime.tryParse(pbStr)?.toUtc(); + + if (local == null) return false; + if (pb == null) return true; + + return local.difference(pb).inSeconds > 2; + } + + Future isBackgroundSyncEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('background_sync_enabled') ?? true; + } + + void triggerBackgroundSync() async { + if (!await isBackgroundSyncEnabled()) { + return; + } + if (pbService.isAuthenticated) { + logger.info("Syncing in the background"); + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(seconds: 1), () { + sync(isFullSync: false).catchError((e, stackTrace) => + logger.severe("Background sync failed.", e, stackTrace)); + }); + } + } + + Future sync({bool isFullSync = true}) async { + final pb = pbService.client; + final userId = pb.authStore.record!.id; + final lastSyncTime = isFullSync ? null : await dbHelper.getLastSync(); + + await _pullJournals(pb, userId, lastSyncTime); + await _pullJournalIssns(pb, userId, lastSyncTime); + await _pushJournals(pb, userId, lastSyncTime); + await _pushJournalIssns(pb, userId, lastSyncTime); + + await _pullSavedQueries(pb, userId, lastSyncTime); + await _pushSavedQueries(pb, userId, lastSyncTime); + + await _pullArticles(pb, userId, lastSyncTime); + await _pushArticles(pb, userId, lastSyncTime); + + await _pullFeedFilters(pb, userId, lastSyncTime); + await _pushFeedFilters(pb, userId, lastSyncTime); + + await _pullKnownUrls(pb, userId, lastSyncTime); + await _pushKnownUrls(pb, userId, lastSyncTime); + + await dbHelper.setLastSync(DateTime.now().toUtc().toIso8601String()); + } + + Future _pullJournals( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + // Get all the user's journals + String filter = 'user = "$userId"'; + if (lastSync != null) { + final formattedDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$formattedDate"'; + } + + final cloudRecords = await pb.collection('journals').getFullList( + filter: filter, + ); + + if (cloudRecords.isEmpty) return; + + logger.info("Delta pull: Found ${cloudRecords.length} updated journals."); + + // I first get the issns to compare locally. If they already exist, + // the local sync_id must be updated so the device match with the cloud + final cloudIssns = await pb.collection('journal_issns').getFullList( + filter: 'user = "$userId"', + ); + + for (final r in cloudRecords) { + final cloudSyncId = r.get('sync_id'); + final cloudTitle = r.get('title'); + final pbUpdatedAt = r.get('updated_at'); + final dateFollowedStr = r.get('date_followed'); + + final journalData = { + 'title': cloudTitle, + 'publisher': r.get('publisher', ''), + 'dateFollowed': + (dateFollowedStr?.isEmpty ?? true) ? null : dateFollowedStr, + 'sync_id': cloudSyncId, + 'is_deleted': (r.get('is_deleted') ?? false) ? 1 : 0, + 'updated_at': pbUpdatedAt, + }; + + // Check if sync_id match + var local = await db.query('journals', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + + // If nothing matches the sync_id, try to match by title/issn + if (local.isEmpty) { + final titleMatches = await db.query('journals', + where: 'LOWER(title) = ?', whereArgs: [cloudTitle.toLowerCase()]); + + for (final potentialMatch in titleMatches) { + final localId = potentialMatch['journal_id'] as int; + + final localIssns = await db.query('journal_issns', + where: 'journal_id = ?', whereArgs: [localId]); + final localIssnSet = + localIssns.map((i) => i['issn'] as String).toSet(); + + final cloudIssnSet = cloudIssns + .where((i) => i.get('journal_id') == r.id) + .map((i) => i.get('issn')) + .toSet(); + + // If an issn match I merge them to avoid duplicates + if (localIssnSet.intersection(cloudIssnSet).isNotEmpty) { + logger.info( + "Merging local journal ${potentialMatch['sync_id']} into cloud ID $cloudSyncId via ISSN match."); + await db.update('journals', {'sync_id': cloudSyncId}, + where: 'journal_id = ?', whereArgs: [localId]); + + local = await db.query('journals', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + break; + } + } + } + + if (local.isEmpty) { + await dbHelper.syncJournalFromCloud(journalData); + } else { + final localUpdatedAt = local.first['updated_at'] as String? ?? ''; + + if (_isNewer(pbUpdatedAt, localUpdatedAt)) { + await dbHelper.syncJournalFromCloud(journalData); + + if (journalData['dateFollowed'] == null && + local.first['dateFollowed'] != null) { + await dbHelper.removeJournalById(local.first['journal_id'] as int); + } + } + } + } + } + + Future _pullJournalIssns( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + String filter = 'user = "$userId"'; + if (lastSync != null) { + final formattedDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$formattedDate"'; + } + + // Get the journal issn and the journal_id from the cloud + final cloudIssns = await pb.collection('journal_issns').getFullList( + filter: filter, + expand: 'journal_id', + ); + + if (cloudIssns.isEmpty) return; + + for (final r in cloudIssns) { + final syncId = r.get('sync_id'); + + final expandedJournal = r.get('expand.journal_id'); + if (expandedJournal == null) continue; + final parentSyncId = expandedJournal.get('sync_id'); + + final localJournal = await db.query('journals', + where: 'sync_id = ?', whereArgs: [parentSyncId], limit: 1); + if (localJournal.isEmpty) continue; + + final journalData = { + 'issn': r.get('issn', ''), + 'journal_id': localJournal.first['journal_id'] as int, + 'sync_id': syncId, + 'is_deleted': (r.get('is_deleted') ?? false) ? 1 : 0, + 'updated_at': r.get('updated_at', ''), + }; + + await dbHelper.syncJournalIssnFromCloud(journalData); + } + } + + Future _pushJournals( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'journals', + where: lastSync != null ? 'updated_at > ?' : null, + whereArgs: lastSync != null ? [lastSync] : null, + ); + + if (rows.isEmpty) return; + + final cloudRecords = + await pb.collection('journals').getFullList(filter: 'user = "$userId"'); + final cloudMap = {for (var r in cloudRecords) r.get('sync_id'): r}; + + for (final j in rows) { + final title = j['title'] as String?; + + if (title == null || title.trim().isEmpty) { + logger.warning( + "Skipping push for journal ID ${j['journal_id']} due to empty title."); + continue; + } + final syncId = j['sync_id']; + final existingCloud = cloudMap[syncId]; + final localDate = j['updated_at'] as String? ?? ''; + final data = { + 'title': title, + 'journal_id': j['journal_id'], + 'publisher': j['publisher'] ?? '', + 'date_followed': (j['dateFollowed'] as String?)?.isNotEmpty == true + ? j['dateFollowed'] + : null, + 'sync_id': syncId, + 'is_deleted': j['is_deleted'] == 1, + 'user': userId, + }; + + if (existingCloud == null) { + // Journal doesn't exist in cloud -> Create it + logger.info("Pushing new journal to cloud: ${data['title']}"); + await pb.collection('journals').create(body: data); + } else { + final pbDate = existingCloud.data['updated_at'] as String? ?? ''; + + if (_isNewer(localDate, pbDate)) { + logger.info("Pushing update for journal: ${data['title']}"); + await pb.collection('journals').update(existingCloud.id, body: data); + } + } + } + } + + Future _pushJournalIssns( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'journal_issns', + where: lastSync != null ? 'updated_at > ?' : null, + whereArgs: lastSync != null ? [lastSync] : null, + ); + + if (rows.isEmpty) return; + + // Get all the issns data from cloud + final cloudIssns = await pb + .collection('journal_issns') + .getFullList(filter: 'user = "$userId"'); + final cloudIssnMap = { + for (var r in cloudIssns) r.get('sync_id'): r + }; + + final cloudJournals = + await pb.collection('journals').getFullList(filter: 'user = "$userId"'); + final journalSyncToPbId = { + for (var r in cloudJournals) r.get('sync_id'): r.id + }; + + for (final issn in rows) { + final journalId = issn['journal_id']; + final journal = await db.query('journals', + where: 'journal_id = ?', whereArgs: [journalId], limit: 1); + if (journal.isEmpty) continue; + + final journalSyncId = journal.first['sync_id']; + final pbJournalRecordId = journalSyncToPbId[journalSyncId]; + if (pbJournalRecordId == null) continue; + + final data = { + 'issn': issn['issn'] ?? '', + 'journal_id': pbJournalRecordId, + 'sync_id': issn['sync_id'], + 'is_deleted': issn['is_deleted'] == 1, + 'user': userId, + }; + // Create the issn in cloud + final existingCloud = cloudIssnMap[issn['sync_id']]; + if (existingCloud == null) { + await pb.collection('journal_issns').create(body: data); + } else { + final pbDate = existingCloud.data['updated_at'] as String? ?? ''; + final localDate = issn['updated_at'] as String? ?? ''; + + if (_isNewer(localDate, pbDate)) { + await pb + .collection('journal_issns') + .update(existingCloud.id, body: data); + } + } + } + } + + Future _pullArticles( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + String filter = 'user = "$userId"'; + if (lastSync != null) { + final pbDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$pbDate"'; + } + + final cloudArticles = await pb.collection('articles').getFullList( + filter: filter, + expand: 'journal_id,query_id', + ); + + logger.info("Delta pull results: ${cloudArticles.length} articles found."); + + for (final r in cloudArticles) { + final syncId = r.get('sync_id'); + final cloudDoi = r.get('doi', ''); + final pbUpdatedAt = r.get('updated_at'); + + final rawDateLiked = r.get('date_liked'); + final cloudDateLiked = + (rawDateLiked?.isEmpty ?? true) ? null : rawDateLiked; + final cloudIsHidden = r.get('is_hidden', false) ? 1 : 0; + + List> localMatches = await db.query( + 'articles', + where: 'sync_id = ? OR (doi = ? AND doi != "")', + whereArgs: [syncId, cloudDoi], + limit: 1, + ); + + int? localJournalId; + final expandedJournal = r.get('expand.journal_id'); + if (expandedJournal != null) { + final res = await db.query('journals', + where: 'sync_id = ?', + whereArgs: [expandedJournal.get('sync_id')], + limit: 1); + if (res.isNotEmpty) localJournalId = res.first['journal_id'] as int; + } + + int? localQueryId; + final expandedQuery = r.get('expand.query_id'); + if (expandedQuery != null) { + final res = await db.query('savedQueries', + where: 'sync_id = ?', + whereArgs: [expandedQuery.get('sync_id')], + limit: 1); + if (res.isNotEmpty) localQueryId = res.first['query_id'] as int; + } + + final articleData = { + 'doi': cloudDoi, + 'title': r.get('title', ''), + 'abstract': r.get('abstract', ''), + 'authors': r.get('authors', ''), + 'publishedDate': r.get('published_date', ''), + 'url': r.get('url', ''), + 'license': r.get('license', ''), + 'licenseName': r.get('license_name', ''), + 'isSavedQuery': r.get('is_saved_query', false) ? 1 : 0, + 'query_id': localQueryId, + 'dateLiked': cloudDateLiked, + 'isHidden': cloudIsHidden, + 'journal_id': localJournalId, + 'sync_id': syncId, + 'updated_at': pbUpdatedAt, + }; + + if (localMatches.isEmpty) { + if (cloudDateLiked != null || cloudIsHidden == 1) { + await db.insert('articles', articleData); + } + } else { + final localRecord = localMatches.first; + final localUpdatedAt = localRecord['updated_at'] as String? ?? ''; + final localSyncId = localRecord['sync_id'] as String?; + + if (localSyncId != syncId) { + await db.update('articles', {'sync_id': syncId}, + where: 'doi = ? AND doi != ""', whereArgs: [cloudDoi]); + } + + if (_isNewer(pbUpdatedAt, localUpdatedAt)) { + await db.update( + 'articles', + { + 'dateLiked': cloudDateLiked, + 'isHidden': cloudIsHidden, + 'updated_at': pbUpdatedAt, + 'journal_id': localJournalId, + 'query_id': localQueryId, + }, + where: 'sync_id = ?', + whereArgs: [syncId], + ); + logger.info("Updated existing article: ${r.get('title')}"); + } + } + } + } + + Future _pushArticles( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'articles', + where: 'updated_at > ?', + whereArgs: [lastSync ?? '1970-01-01T00:00:00Z'], + ); + + if (rows.isEmpty) return; + + final cloudJournals = + await pb.collection('journals').getFullList(filter: 'user = "$userId"'); + final journalSyncToPbId = { + for (var r in cloudJournals) r.get('sync_id'): r.id + }; + + final cloudQueries = await pb + .collection('saved_queries') + .getFullList(filter: 'user = "$userId"'); + final querySyncToPbId = { + for (var r in cloudQueries) r.get('sync_id'): r.id + }; + + final cloudArticles = + await pb.collection('articles').getFullList(filter: 'user = "$userId"'); + final cloudMap = {for (var r in cloudArticles) r.get('sync_id'): r}; + + for (final a in rows) { + try { + final syncId = a['sync_id'] as String; + final existingCloud = cloudMap[syncId]; + final localDateLiked = a['dateLiked'] as String?; + final localIsHidden = a['isHidden'] == 1; + final isSavedQuery = a['isSavedQuery'] == 1; + + String? pbJournalRecordId; + if (a['journal_id'] != null) { + final journalRes = await db.query('journals', + where: 'journal_id = ?', whereArgs: [a['journal_id']], limit: 1); + if (journalRes.isNotEmpty) { + pbJournalRecordId = journalSyncToPbId[journalRes.first['sync_id']]; + } + } + + String? pbQueryRecordId; + if (isSavedQuery && a['query_id'] != null) { + final queryRes = await db.query('savedQueries', + where: 'query_id = ?', whereArgs: [a['query_id']], limit: 1); + if (queryRes.isNotEmpty) { + pbQueryRecordId = querySyncToPbId[queryRes.first['sync_id']]; + } + } + + if (existingCloud == null) { + if (localDateLiked == null && !localIsHidden) { + continue; + } + + final data = { + 'doi': a['doi'] ?? '', + 'title': a['title'] ?? '', + 'abstract': a['abstract'] ?? '', + 'authors': a['authors'] ?? '', + 'published_date': a['publishedDate'] ?? '', + 'url': a['url'] ?? '', + 'license': a['license'] ?? '', + 'license_name': a['licenseName'] ?? '', + 'is_saved_query': isSavedQuery, + 'date_liked': localDateLiked, + 'is_hidden': localIsHidden, + 'sync_id': syncId, + 'user': userId, + 'journal_id': pbJournalRecordId, + 'query_id': pbQueryRecordId, + }; + await pb.collection('articles').create(body: data); + logger.info("Created new article in cloud: ${a['title']}"); + } else { + final pbUpdatedAt = existingCloud.data['updated_at'] as String? ?? ''; + final localUpdatedAt = a['updated_at'] as String? ?? ''; + + if (_isNewer(localUpdatedAt, pbUpdatedAt)) { + await pb.collection('articles').update(existingCloud.id, body: { + 'date_liked': localDateLiked, + 'is_hidden': localIsHidden, + if (pbJournalRecordId != null) 'journal_id': pbJournalRecordId, + if (pbQueryRecordId != null) 'query_id': pbQueryRecordId, + 'is_saved_query': isSavedQuery, + }); + logger.info( + "Pushed article update: $syncId (Hidden: $localIsHidden, Liked: $localDateLiked)"); + } + } + } catch (e) { + logger.warning("Failed to push article ${a['sync_id']}: $e"); + } + } + } + + Future _pullFeedFilters( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + String filter = 'user = "$userId"'; + if (lastSync != null) { + final pbDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$pbDate"'; + } + + final cloudRecords = await pb.collection('feed_filters').getFullList( + filter: filter, + ); + + if (cloudRecords.isEmpty) return; + + logger + .info("Delta pull: Found ${cloudRecords.length} updated feed filters."); + + for (final r in cloudRecords) { + final cloudSyncId = r.get('sync_id'); + final cloudName = r.get('name'); + final pbUpdatedAt = r.get('updated_at'); + + final filterData = { + 'name': cloudName, + 'includedKeywords': r.get('included_keywords'), + 'excludedKeywords': r.get('excluded_keywords'), + 'journals': r.get('journals'), + 'date_mode': r.get('date_mode'), + 'date_after': r.get('date_after'), + 'date_before': r.get('date_before'), + 'dateCreated': r.get('date_created'), + 'sync_id': cloudSyncId, + 'is_deleted': r.get('is_deleted') ? 1 : 0, + 'updated_at': pbUpdatedAt, + }; + + var local = await db.query('feed_filters', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + + if (local.isEmpty) { + final nameMatches = await db.query( + 'feed_filters', + where: 'LOWER(name) = ?', + whereArgs: [cloudName.toLowerCase()], + limit: 1, + ); + + if (nameMatches.isNotEmpty) { + final localId = nameMatches.first['id']; + logger.info( + "Merging local filter '$cloudName' into cloud sync_id: $cloudSyncId via name match."); + await db.update('feed_filters', {'sync_id': cloudSyncId}, + where: 'id = ?', whereArgs: [localId]); + + local = await db.query('feed_filters', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + } + } + + if (local.isEmpty) { + if (filterData['is_deleted'] == 0) { + await db.insert('feed_filters', filterData); + logger.info("Inserted new feed filter: ${filterData['name']}"); + } + } else { + final localUpdatedAt = local.first['updated_at'] as String? ?? ''; + + if (_isNewer(pbUpdatedAt, localUpdatedAt)) { + await db.update('feed_filters', filterData, + where: 'sync_id = ?', whereArgs: [cloudSyncId]); + logger.info("Updated feed filter: ${filterData['name']}"); + } + } + } + } + + Future _pushFeedFilters( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'feed_filters', + where: lastSync != null ? 'updated_at > ?' : null, + whereArgs: lastSync != null ? [lastSync] : null, + ); + + if (rows.isEmpty) return; + + final cloudRecords = await pb.collection('feed_filters').getFullList( + filter: 'user = "$userId"', + ); + final cloudMap = {for (var r in cloudRecords) r.get('sync_id'): r}; + + for (final f in rows) { + final syncId = f['sync_id'] as String; + final existingCloud = cloudMap[syncId]; + final localUpdatedAt = f['updated_at'] as String? ?? ''; + + final data = { + 'name': f['name'], + 'included_keywords': f['includedKeywords'], + 'excluded_keywords': f['excludedKeywords'], + 'journals': f['journals'], + 'date_mode': f['date_mode'], + 'date_after': f['date_after'], + 'date_before': f['date_before'], + 'date_created': f['dateCreated'], + 'sync_id': syncId, + 'is_deleted': f['is_deleted'] == 1, + 'user': userId, + }; + + if (existingCloud == null) { + logger.info("Pushing new feed filter to cloud: ${data['name']}"); + await pb.collection('feed_filters').create(body: data); + } else { + final pbUpdatedAt = existingCloud.get('updated_at'); + if (_isNewer(localUpdatedAt, pbUpdatedAt)) { + logger.info("Pushing update for feed filter: ${data['name']}"); + await pb + .collection('feed_filters') + .update(existingCloud.id, body: data); + } + } + } + } + + Future _pullKnownUrls( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + String filter = 'user = "$userId"'; + if (lastSync != null) { + final pbDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$pbDate"'; + } + + final cloudRecords = await pb.collection('known_urls').getFullList( + filter: filter, + ); + + if (cloudRecords.isEmpty) return; + + logger.info("Delta pull: Found ${cloudRecords.length} updated known URLs."); + + for (final r in cloudRecords) { + final cloudSyncId = r.get('sync_id'); + final cloudUrl = r.get('url'); + final pbUpdatedAt = r.get('updated_at'); + + final urlData = { + 'url': cloudUrl, + 'proxySuccess': r.get('proxy_success', 0), + 'sync_id': cloudSyncId, + 'is_deleted': r.get('is_deleted') ? 1 : 0, + 'updated_at': pbUpdatedAt, + }; + + if (r.get('is_deleted')) { + await db.update( + 'knownUrls', + {'is_deleted': 1, 'updated_at': pbUpdatedAt}, + where: 'sync_id = ? AND is_deleted = 0', + whereArgs: [cloudSyncId], + ); + continue; + } + + // Similar to the others, checks for existing URLs, if there are + // their local sync_id is updated with the cloud value to merge them and + // avoid duplicates + var local = await db.query('knownUrls', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + + if (local.isEmpty) { + final urlMatches = await db.query( + 'knownUrls', + where: 'url = ?', + whereArgs: [cloudUrl], + limit: 1, + ); + + if (urlMatches.isNotEmpty) { + final localId = urlMatches.first['id']; + logger.info( + "Merging local URL for '$cloudUrl' into cloud sync_id: $cloudSyncId"); + + await db.update( + 'knownUrls', + {'sync_id': cloudSyncId}, + where: 'id = ?', + whereArgs: [localId], + ); + + local = await db.query('knownUrls', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + } + } + + if (local.isEmpty) { + await db.insert('knownUrls', urlData); + logger.info("Inserted new known URL: ${urlData['url']}"); + } else { + final localUpdatedAt = local.first['updated_at'] as String? ?? ''; + if (_isNewer(pbUpdatedAt, localUpdatedAt)) { + await db.update('knownUrls', urlData, + where: 'sync_id = ?', whereArgs: [cloudSyncId]); + } + } + } + } + + Future _pushKnownUrls( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'knownUrls', + where: lastSync != null ? 'updated_at > ?' : null, + whereArgs: lastSync != null ? [lastSync] : null, + ); + + if (rows.isEmpty) return; + + final cloudRecords = await pb.collection('known_urls').getFullList( + filter: 'user = "$userId"', + ); + final cloudMap = {for (var r in cloudRecords) r.get('sync_id'): r}; + + for (final row in rows) { + final syncId = row['sync_id'] as String; + final existingCloud = cloudMap[syncId]; + final localUpdatedAt = row['updated_at'] as String? ?? ''; + + final data = { + 'url': row['url'], + 'proxy_success': row['proxySuccess'], + 'sync_id': syncId, + 'is_deleted': row['is_deleted'] == 1, + 'user': userId, + }; + + if (existingCloud == null) { + logger.info("Pushing new known URL: ${data['url']}"); + await pb.collection('known_urls').create(body: data); + } else { + final pbUpdatedAt = existingCloud.get('updated_at'); + if (_isNewer(localUpdatedAt, pbUpdatedAt)) { + logger.info("Pushing update for known URL: ${data['url']}"); + await pb + .collection('known_urls') + .update(existingCloud.id, body: data); + } + } + } + } + + Future _pullSavedQueries( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + String filter = 'user = "$userId"'; + if (lastSync != null) { + final pbDate = lastSync.replaceAll('T', ' ').substring(0, 19); + filter += ' && updated_at > "$pbDate"'; + } + + final cloudRecords = await pb.collection('saved_queries').getFullList( + filter: filter, + ); + + if (cloudRecords.isEmpty) return; + + logger.info( + "Delta pull: Found ${cloudRecords.length} updated saved queries."); + + for (final r in cloudRecords) { + final cloudSyncId = r.get('sync_id'); + final pbUpdatedAt = r.get('updated_at'); + + final queryData = { + 'queryName': r.get('query_name'), + 'queryParams': r.get('query_params'), + 'dateSaved': r.get('date_saved'), + 'includeInFeed': r.get('include_in_feed') ? 1 : 0, + 'queryProvider': r.get('query_provider'), + 'sync_id': cloudSyncId, + 'is_deleted': r.get('is_deleted') ? 1 : 0, + 'updated_at': pbUpdatedAt, + }; + + var local = await db.query('savedQueries', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + + if (local.isEmpty) { + final match = await db.query( + 'savedQueries', + where: 'queryName = ? AND queryParams = ?', + whereArgs: [queryData['queryName'], queryData['queryParams']], + limit: 1, + ); + + if (match.isNotEmpty) { + logger.info( + "Merging local query '${queryData['queryName']}' into cloud sync_id: $cloudSyncId"); + await db.update('savedQueries', {'sync_id': cloudSyncId}, + where: 'query_id = ?', whereArgs: [match.first['query_id']]); + + local = await db.query('savedQueries', + where: 'sync_id = ?', whereArgs: [cloudSyncId], limit: 1); + } + } + + if (local.isEmpty) { + if (queryData['is_deleted'] == 0) { + await db.insert('savedQueries', queryData); + logger.info("Inserted new saved query: ${queryData['queryName']}"); + } + } else { + final localUpdatedAt = local.first['updated_at'] as String? ?? ''; + + if (_isNewer(pbUpdatedAt, localUpdatedAt)) { + await db.update('savedQueries', queryData, + where: 'sync_id = ?', whereArgs: [cloudSyncId]); + logger.info("Updated saved query: ${queryData['queryName']}"); + } + } + } + } + + Future _pushSavedQueries( + PocketBase pb, String userId, String? lastSync) async { + final db = await dbHelper.database; + + final rows = await db.query( + 'savedQueries', + where: lastSync != null ? 'updated_at > ?' : null, + whereArgs: lastSync != null ? [lastSync] : null, + ); + + if (rows.isEmpty) return; + + final cloudRecords = await pb.collection('saved_queries').getFullList( + filter: 'user = "$userId"', + ); + final cloudMap = {for (var r in cloudRecords) r.get('sync_id'): r}; + + for (final row in rows) { + final syncId = row['sync_id'] as String; + final existingCloud = cloudMap[syncId]; + final localUpdatedAt = row['updated_at'] as String? ?? ''; + + final data = { + 'query_name': row['queryName'], + 'query_params': row['queryParams'], + 'date_saved': row['dateSaved'], + 'include_in_feed': row['includeInFeed'] == 1, + 'query_provider': row['queryProvider'], + 'sync_id': syncId, + 'is_deleted': row['is_deleted'] == 1, + 'user': userId, + }; + + if (existingCloud == null) { + logger.info("Pushing new saved query: ${data['query_name']}"); + await pb.collection('saved_queries').create(body: data); + } else { + final pbUpdatedAt = existingCloud.get('updated_at'); + if (_isNewer(localUpdatedAt, pbUpdatedAt)) { + logger.info("Pushing update for saved query: ${data['query_name']}"); + await pb + .collection('saved_queries') + .update(existingCloud.id, body: data); + } + } + } + } +} diff --git a/lib/widgets/custom_feed_bottom_sheet.dart b/lib/widgets/custom_feed_bottom_sheet.dart index 6f936a66..e37044f6 100644 --- a/lib/widgets/custom_feed_bottom_sheet.dart +++ b/lib/widgets/custom_feed_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/services/sync_service.dart'; class CustomizeFeedBottomSheet extends StatefulWidget { final List followedJournals; @@ -67,12 +68,14 @@ class CustomizeFeedBottomSheetState extends State { _dateMode = widget.initialDateMode ?? 'none'; - if (widget.initialDateAfter != null) { - _publishedDateAfter = DateTime.parse(widget.initialDateAfter!); + if (widget.initialDateAfter != null && + widget.initialDateAfter!.isNotEmpty) { + _publishedDateAfter = DateTime.tryParse(widget.initialDateAfter!); } - if (widget.initialDateBefore != null) { - _publishedDateBefore = DateTime.parse(widget.initialDateBefore!); + if (widget.initialDateBefore != null && + widget.initialDateBefore!.isNotEmpty) { + _publishedDateBefore = DateTime.tryParse(widget.initialDateBefore!); } _nameController = TextEditingController(text: widget.initialName ?? ''); @@ -410,6 +413,7 @@ class CustomizeFeedBottomSheetState extends State { child: FloatingActionButton.extended( onPressed: () async { final db = DatabaseHelper(); + final syncManager = SyncManager(); final feedName = _nameController.text.trim(); final include = _includeChips.join(' '); final exclude = _excludeChips.join(' '); @@ -452,6 +456,7 @@ class CustomizeFeedBottomSheetState extends State { dateAfter: _publishedDateAfter?.toIso8601String(), dateBefore: _publishedDateBefore?.toIso8601String(), ); + syncManager.triggerBackgroundSync(); } else { await db.insertFeedFilter( name: feedName, @@ -462,6 +467,7 @@ class CustomizeFeedBottomSheetState extends State { dateAfter: _publishedDateAfter?.toIso8601String(), dateBefore: _publishedDateBefore?.toIso8601String(), ); + syncManager.triggerBackgroundSync(); } widget.onApply( diff --git a/lib/widgets/journal_follow_button.dart b/lib/widgets/journal_follow_button.dart index 3890fee4..7bb0e010 100644 --- a/lib/widgets/journal_follow_button.dart +++ b/lib/widgets/journal_follow_button.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../models/crossref_journals_models.dart' as Journals; -import '../services/database_helper.dart'; -import '../models/journal_entity.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/models/journal_entity.dart'; +import 'package:wispar/services/sync_service.dart'; enum ButtonType { text, outlined } @@ -13,12 +14,12 @@ class FollowButton extends StatelessWidget { final ButtonType buttonType; const FollowButton({ - Key? key, + super.key, required this.item, required this.isFollowed, required this.onFollowStatusChanged, this.buttonType = ButtonType.text, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -49,6 +50,7 @@ class FollowButton extends StatelessWidget { void toggleFollowStatus(BuildContext context) async { final dbHelper = DatabaseHelper(); + final syncManager = SyncManager(); int? journalId = await dbHelper.getJournalIdByIssns(item.issn); bool currentlyFollowed = false; if (journalId != null) { @@ -69,6 +71,7 @@ class FollowButton extends StatelessWidget { ), ); } + syncManager.triggerBackgroundSync(); //await dbHelper.clearCachedPublications(); onFollowStatusChanged(!currentlyFollowed); diff --git a/lib/widgets/journals_tab_content.dart b/lib/widgets/journals_tab_content.dart index f02f113d..e4b0f79f 100644 --- a/lib/widgets/journals_tab_content.dart +++ b/lib/widgets/journals_tab_content.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../models/journal_entity.dart'; -import '../widgets/sort_dialog.dart'; -import '../services/database_helper.dart'; -import '../widgets/journal_card.dart'; +import 'package:wispar/services/sync_service.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/models/journal_entity.dart'; +import 'package:wispar/widgets/sort_dialog.dart'; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/widgets/journal_card.dart'; class JournalsTabContent extends StatefulWidget { final int initialSortBy; @@ -12,18 +13,18 @@ class JournalsTabContent extends StatefulWidget { final Function(int) onSortOrderChanged; const JournalsTabContent({ - Key? key, + super.key, required this.initialSortBy, required this.initialSortOrder, required this.onSortByChanged, required this.onSortOrderChanged, - }) : super(key: key); + }); @override - _JournalsTabContentState createState() => _JournalsTabContentState(); + JournalsTabContentState createState() => JournalsTabContentState(); } -class _JournalsTabContentState extends State { +class JournalsTabContentState extends State { late DatabaseHelper dbHelper; bool _isEditing = false; @@ -153,7 +154,9 @@ class _JournalsTabContentState extends State { } Future _unfollowJournal(BuildContext context, Journal journal) async { + final syncManager = SyncManager(); await dbHelper.removeJournal(journal.issn); setState(() {}); + syncManager.triggerBackgroundSync(); } } diff --git a/lib/widgets/publication_card/publication_card.dart b/lib/widgets/publication_card/publication_card.dart index c6cb5c8c..33549259 100644 --- a/lib/widgets/publication_card/publication_card.dart +++ b/lib/widgets/publication_card/publication_card.dart @@ -8,6 +8,7 @@ import 'package:wispar/screens/journals_details_screen.dart'; import 'package:wispar/screens/article_website.dart'; import 'package:wispar/services/database_helper.dart'; import 'package:wispar/services/zotero_api.dart'; +import 'package:wispar/services/sync_service.dart'; import 'package:wispar/widgets/publication_card/publication_card_content.dart'; import 'package:wispar/widgets/publication_card/card_swipe_background.dart'; import 'package:wispar/widgets/zotero_bottomsheet.dart'; @@ -95,6 +96,8 @@ class PublicationCardState extends State with SingleTickerProviderStateMixin { bool isLiked = false; late DatabaseHelper databaseHelper; + final syncManager = SyncManager(); + late AnimationController _swipeController; late Animation _slideAnimation; double _dragExtent = 0.0; @@ -150,6 +153,7 @@ class PublicationCardState extends State } else { await databaseHelper.removeFavorite(widget.doi); } + syncManager.triggerBackgroundSync(); widget.onFavoriteChanged?.call(); } @@ -171,6 +175,7 @@ class PublicationCardState extends State } else { await databaseHelper.hideArticle(widget.doi); } + syncManager.triggerBackgroundSync(); widget.onHide?.call(); break; case SwipeAction.favorite: @@ -180,6 +185,7 @@ class PublicationCardState extends State } else { await databaseHelper.removeFavorite(widget.doi); } + syncManager.triggerBackgroundSync(); widget.onFavoriteChanged?.call(); break; case SwipeAction.sendToZotero: diff --git a/lib/widgets/sync_auth_form.dart b/lib/widgets/sync_auth_form.dart new file mode 100644 index 00000000..5d8d0381 --- /dev/null +++ b/lib/widgets/sync_auth_form.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/pocketbase_service.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SyncAuthForm extends StatefulWidget { + final VoidCallback onLoginSuccess; + + const SyncAuthForm({super.key, required this.onLoginSuccess}); + + @override + State createState() => _SyncAuthFormState(); +} + +class _SyncAuthFormState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _urlController = TextEditingController(); + + final pbService = PocketBaseService(); + final logger = LogsService().logger; + + bool _isSyncing = false; + bool _isRegisterMode = false; + bool _isSelfHosted = false; + String? _errorMessage; + + int _resendCooldown = 0; + Timer? _cooldownTimer; + + @override + void initState() { + super.initState(); + _isSelfHosted = pbService.baseURL != 'https://sync.wispar.app'; + _urlController.text = _isSelfHosted ? pbService.baseURL : ''; + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + _emailController.dispose(); + _passwordController.dispose(); + _urlController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + final email = _emailController.text.trim(); + final password = _passwordController.text.trim(); + final url = _urlController.text.trim(); + + if (_isSelfHosted && url.isEmpty) { + setState(() => _errorMessage = AppLocalizations.of(context)!.invalidUrl); + return; + } + + if (email.isEmpty) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.pleaseEnterEmail); + return; + } + + final bool emailValid = RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(email); + + if (!emailValid) { + setState(() => + _errorMessage = AppLocalizations.of(context)!.pleaseEnterValidEmail); + return; + } + + if (password.isEmpty) { + setState(() => + _errorMessage = AppLocalizations.of(context)!.pleaseEnterPassword); + return; + } + setState(() { + _isSyncing = true; + _errorMessage = null; + }); + + try { + if (_isSelfHosted) { + await pbService.updateCustomUrl(url); + } + + if (_isRegisterMode) { + await pbService.register( + email, + password, + ); + // Don't log the user until the user verify their email + pbService.client.authStore.clear(); + + if (!mounted) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.checkEmail), + content: Text(AppLocalizations.of(context)!.checkEmailDescription), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + setState(() => _isRegisterMode = false); + _passwordController.clear(); + }, + child: Text(AppLocalizations.of(context)!.close), + ), + ], + ), + ); + } else { + await pbService.client.collection('users').authWithPassword( + email, + password, + ); + + if (!pbService.isVerified) { + pbService.client.authStore.clear(); + setState(() { + _errorMessage = AppLocalizations.of(context)!.emailNotVerifiedError; + }); + return; + } + logger.info('Logged in a cloud account.'); + widget.onLoginSuccess(); + } + } catch (e, stackTrace) { + if (mounted) { + setState(() => _errorMessage = _getCleanErrorMessage(e)); + } + logger.severe('Failed to authenticate.', e, stackTrace); + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + + Future _handleResendVerification() async { + final email = _emailController.text.trim(); + if (email.isEmpty) return; + + setState(() => _isSyncing = true); + try { + await pbService.resendVerification(email); + _startResendCooldown(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.checkEmailDescription)), + ); + } catch (e) { + if (mounted) setState(() => _errorMessage = _getCleanErrorMessage(e)); + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + + void _startResendCooldown() { + setState(() => _resendCooldown = 60); + _cooldownTimer?.cancel(); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_resendCooldown == 0) { + timer.cancel(); + } else { + setState(() => _resendCooldown--); + } + }); + } + + Future _handleForgotPassword() async { + final email = _emailController.text.trim(); + if (email.isEmpty) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.pleaseEnterEmail); + return; + } + setState(() { + _isSyncing = true; + _errorMessage = null; + }); + try { + await pbService.requestPasswordReset(email); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.passwordResetSent)), + ); + } catch (e) { + if (mounted) setState(() => _errorMessage = _getCleanErrorMessage(e)); + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + + String _getCleanErrorMessage(dynamic e) { + if (e is! ClientException) return e.toString(); + final data = e.response['data'] as Map? ?? {}; + if (e.statusCode == 400) { + if (data.containsKey('identity') || data.containsKey('email')) { + final emailErr = data['identity'] ?? data['email']; + final code = emailErr['code']; + if (code == 'validation_required') { + return AppLocalizations.of(context)!.pleaseEnterEmail; + } + if (code == 'validation_not_unique') { + return AppLocalizations.of(context)!.accountAlreadyExists; + } + if (code == 'validation_is_email') { + return AppLocalizations.of(context)!.pleaseEnterValidEmail; + } + } + if (data.containsKey('password')) { + final passErr = data['password']; + final code = passErr['code']; + if (code == 'validation_required') { + return AppLocalizations.of(context)!.pleaseEnterPassword; + } + if (code == 'validation_min_text_constraint') { + return AppLocalizations.of(context)!.passwordTooShort; + } + } + return AppLocalizations.of(context)!.checkdetailAndTryAgain; + } + if (e.statusCode == 401 || e.statusCode == 404) { + return AppLocalizations.of(context)!.invalidEmailOrPassword; + } + return AppLocalizations.of(context)!.cantConnectServer; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SegmentedButton( + segments: [ + const ButtonSegment( + value: false, + label: Text('Wispar Sync'), + icon: Icon(Icons.cloud)), + ButtonSegment( + value: true, + label: Text(AppLocalizations.of(context)!.selfHosted), + icon: const Icon(Icons.dns)), + ], + selected: {_isSelfHosted}, + onSelectionChanged: (value) async { + setState(() { + _errorMessage = null; + _isSelfHosted = value.first; + }); + if (!_isSelfHosted) { + const prodUrl = 'https://sync.wispar.app'; + await pbService.updateCustomUrl(prodUrl); + _urlController.text = prodUrl; + } else { + _urlController.text = ''; + } + }, + ), + const SizedBox(height: 16), + if (_isSelfHosted) + TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.serverUrl, + hintText: 'http://192.168.1.50:8090'), + ), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: + InputDecoration(labelText: AppLocalizations.of(context)!.email), + ), + TextField( + controller: _passwordController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.password), + obscureText: true, + ), + if (!_isRegisterMode && !_isSelfHosted) + TextButton( + onPressed: _isSyncing ? null : _handleForgotPassword, + child: Text(AppLocalizations.of(context)!.forgotPassword), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text(_errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.red, fontWeight: FontWeight.w500)), + ), + if (_errorMessage != null && + _errorMessage!.contains("verify") && + !_isRegisterMode) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextButton.icon( + onPressed: (_resendCooldown > 0 || _isSyncing) + ? null + : _handleResendVerification, + icon: const Icon(Icons.email), + label: Text(_resendCooldown > 0 + ? AppLocalizations.of(context)! + .waitResendEmail(_resendCooldown) + : AppLocalizations.of(context)!.resendEmail), + ), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSyncing ? null : _handleLogin, + child: _isSyncing + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Text(_isRegisterMode + ? AppLocalizations.of(context)!.signUp + : AppLocalizations.of(context)!.login), + ), + ), + if (!_isSelfHosted) + TextButton( + onPressed: () => setState(() => _isRegisterMode = !_isRegisterMode), + child: Text(_isRegisterMode + ? AppLocalizations.of(context)!.haveAnAccount + : AppLocalizations.of(context)!.needAnAccount), + ), + SizedBox(height: 8), + const Divider(), + SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton.tonalIcon( + onPressed: () { + launchUrl( + Uri.parse( + 'https://github.com/Scriptbash/Wispar/blob/main/PRIVACY.md'), + ); + }, + label: Text(AppLocalizations.of(context)!.privacyPolicy)), + FilledButton.tonalIcon( + onPressed: () { + launchUrl( + Uri.parse( + 'https://wispar.app/docs/initial-setup/cloud-sync'), + ); + }, + label: Text(AppLocalizations.of(context)!.documentation)) + ], + ), + ], + ); + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 00c2c0b7..7d372d80 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -546,6 +546,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MAX4AK5MU7; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -571,9 +572,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_IDENTITY = ""; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = MAX4AK5MU7; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; LD_RUNPATH_SEARCH_PATHS = ( @@ -624,6 +626,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = MAX4AK5MU7; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -680,6 +683,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MAX4AK5MU7; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -705,9 +709,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_IDENTITY = ""; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = MAX4AK5MU7; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; LD_RUNPATH_SEARCH_PATHS = ( @@ -727,9 +732,22 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_IDENTITY = ""; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = MAX4AK5MU7; + ENABLE_APP_SANDBOX = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 4dde6418..19b01bd0 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,13 +6,17 @@ com.apple.security.cs.allow-jit - com.apple.security.network.server - - com.apple.security.network.client - com.apple.security.files.user-selected.read-only com.apple.security.files.user-selected.read-write + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + MAX4AK5MU7.app.wispar.wispar + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 1d4b2c3f..f926b2d9 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,11 +4,15 @@ com.apple.security.app-sandbox - com.apple.security.network.client - com.apple.security.files.user-selected.read-only com.apple.security.files.user-selected.read-write + com.apple.security.network.client + + keychain-access-groups + + MAX4AK5MU7.app.wispar.wispar + diff --git a/pubspec.lock b/pubspec.lock index 2ab31f90..e2bc7acf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -379,6 +379,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_staggered_grid_view: dependency: transitive description: @@ -797,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pocketbase: + dependency: "direct main" + description: + name: pocketbase + sha256: b94e63f00ce29c5f465e8122ae63cdf4e3c37f9b3dc6d04f0dfe6625bcdb8839 + url: "https://pub.dev" + source: hosted + version: "0.23.2" posix: dependency: transitive description: @@ -1163,7 +1219,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" diff --git a/pubspec.yaml b/pubspec.yaml index 23722914..1eda6654 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.0+30 +version: 0.10.0+32 environment: sdk: '>=3.2.3 <4.0.0' @@ -68,6 +68,9 @@ dependencies: archive: ^4.0.7 window_manager: ^0.5.1 infinite_scroll_pagination: ^5.1.1 + uuid: ^4.5.3 + pocketbase: ^0.23.2 + flutter_secure_storage: ^10.0.0 dev_dependencies: flutter_test: diff --git a/tables.json b/tables.json new file mode 100644 index 00000000..fef80569 --- /dev/null +++ b/tables.json @@ -0,0 +1,971 @@ +[ + { + "id": "pbc_4287850865", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": null, + "name": "articles", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1720980602", + "max": 0, + "min": 0, + "name": "doi", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1966432161", + "max": 0, + "min": 0, + "name": "abstract", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2383161937", + "max": 0, + "min": 0, + "name": "authors", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2651326123", + "max": 0, + "min": 0, + "name": "published_date", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1466496025", + "max": 0, + "min": 0, + "name": "license", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3570150165", + "max": 0, + "min": 0, + "name": "license_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1894327094", + "max": 0, + "min": 0, + "name": "date_liked", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool2550662023", + "name": "is_saved_query", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool3625215074", + "name": "is_hidden", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number4019482521", + "max": null, + "min": null, + "name": "query_id", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1407978968", + "hidden": false, + "id": "relation1200523266", + "maxSelect": 1, + "minSelect": 0, + "name": "journal_id", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2516400126", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": "", + "name": "feed_filters", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2677495918", + "max": 0, + "min": 0, + "name": "included_keywords", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1580056603", + "max": 0, + "min": 0, + "name": "excluded_keywords", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text329405889", + "max": 0, + "min": 0, + "name": "journals", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3071594079", + "max": 0, + "min": 0, + "name": "date_mode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text867477197", + "max": 0, + "min": 0, + "name": "date_after", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1016029773", + "max": 0, + "min": 0, + "name": "date_before", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1611093572", + "max": 0, + "min": 0, + "name": "date_created", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1130519967", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_3k0Uuh0N6x` ON `feed_filters` (\n `sync_id`,\n `user`\n)" + ], + "system": false + }, + { + "id": "pbc_1168135086", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": null, + "name": "journal_issns", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2680543222", + "max": 0, + "min": 0, + "name": "issn", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1407978968", + "hidden": false, + "id": "relation1200523266", + "maxSelect": 1, + "minSelect": 0, + "name": "journal_id", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1130519967", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_8MDB5nEw4B` ON `journal_issns` (\n `sync_id`,\n `user`\n)" + ], + "system": false + }, + { + "id": "pbc_1407978968", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": null, + "name": "journals", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1200523266", + "max": 0, + "min": 0, + "name": "journal_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2632504646", + "max": 0, + "min": 0, + "name": "publisher", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4191893697", + "max": 0, + "min": 0, + "name": "date_followed", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1130519967", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_XYiqAr9L85` ON `journals` (\n `sync_id`,\n `user`\n)" + ], + "system": false + }, + { + "id": "pbc_2135740444", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": null, + "name": "known_urls", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number2032735959", + "max": null, + "min": null, + "name": "proxy_success", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1130519967", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_7VOnXOFh7f` ON `known_urls` (\n `sync_id`,\n `user`\n)" + ], + "system": false + }, + { + "id": "pbc_2414388848", + "listRule": "user = @request.auth.id && @request.auth.verified = true", + "viewRule": "user = @request.auth.id && @request.auth.verified = true", + "createRule": "@request.auth.id != \"\" && @request.body.user = @request.auth.id && @request.auth.verified = true", + "updateRule": "user = @request.auth.id && @request.auth.verified = true", + "deleteRule": null, + "name": "saved_queries", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1368530910", + "max": 0, + "min": 0, + "name": "query_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4237959067", + "max": 0, + "min": 0, + "name": "query_params", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1171850702", + "max": 0, + "min": 0, + "name": "date_saved", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1431355797", + "max": 0, + "min": 0, + "name": "query_provider", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool3739537533", + "name": "include_in_feed", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool4245145851", + "name": "is_deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4199597090", + "max": 0, + "min": 0, + "name": "sync_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate1130519967", + "name": "updated_at", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_pxDtrgMAjw` ON `saved_queries` (\n `sync_id`,\n `user`\n)" + ], + "system": false + } +] \ No newline at end of file diff --git a/website/docs/initial-setup/cloud-sync.mdx b/website/docs/initial-setup/cloud-sync.mdx new file mode 100644 index 00000000..f7962362 --- /dev/null +++ b/website/docs/initial-setup/cloud-sync.mdx @@ -0,0 +1,71 @@ +--- +sidebar_position: 1 +--- + +# Devices synchronization + +Wispar has an optional cloud syncing feature to synchronize your database on multiple devices. It uses [PocketBase](https://pocketbase.io) to mirror the app's database. + +You can either use Wispar Sync, a hosted sync service, or self-host the sync backend to stay in control of your data. + +## Wispar Sync (hosted) + +Wispar Sync is the easiest way to keep your devices in sync without managing any infrastructure. + +:::warning +Before choosing this option, please take the time to read the [Privacy policy](https://github.com/Scriptbash/Wispar/blob/main/PRIVACY.md). +::: + +**1. Create an account** + +1. Open the Wispar app and navigate to **Cloud sync settings**. +2. Ensure the toggle is set to **Wispar Sync** (this is the default). +3. Tap on **Need an account?** to switch to registration mode. +4. Enter your email and a strong password, then tap **Sign up**. + +**2. Verify your email** + +- For security reasons, your account must be verified before you can start syncing. +- Check your inbox for a verification email from Wispar. +- Click the verification link inside the email. + +:::info +If you don't receive the email, you can use the **Resend email** button in the app (available after a failed login attempt with an unverified account). +::: + +**3. Sign in and sync** + +1. Return to the login screen in Wispar. +2. Enter your credentials and tap **Login**. +3. Once authenticated, your data will begin its initial sync. +4. You can toggle **Background Sync** to allow Wispar to keep your data updated automatically while you are using the app. + +:::info +Syncing only happens when the app is open. +::: + +## Self-hosted + +:::warning +Do not expose your PocketBase instance to the internet unless you are aware of the risks and know what you are doing! +::: + +1. **Download PocketBase:** Download the [latest executable](https://pocketbase.io/docs/) for your operating system. +2. **Launch the server:** Open your terminal in the folder where you downloaded the file and run: + ```bash + ./pocketbase serve + ``` + +3. **Initial setup:** Access the dashboard at `http://127.0.0.1:8090/_/` to create your initial admin account. +4. **Prepare schema:** Download or copy the [tables.json](https://github.com/Scriptbash/Wispar/blob/sync/tables.json) file from the Wispar repository. +5. **Import collections:** In the PocketBase sidebar, go to **Settings > Import collections**. + - Paste the JSON content or upload the file. + - Ensure "Merge with existing collections" is toggled **ON** before clicking **Import**. + + +6. **Create your Wispar user:** + - Navigate to the **Collections** menu and select the **users** table. + - Click **+ New record**. + - Enter an email and password. (Note: The email doesn't need to be real unless you plan to configure mail settings later). + - **Important:** Toggle the **Verified** switch to **ON**. If this is off, Wispar will reject the login. +7. **Connect Wispar:** Open the Wispar app, select **Self-Hosted** in the sync settings, and enter your Server URL (e.g., `http://your-ip:8090`). That's it! \ No newline at end of file diff --git a/website/docs/initial-setup/link-with-zotero.mdx b/website/docs/initial-setup/link-with-zotero.mdx index fbe1e357..aad085e9 100644 --- a/website/docs/initial-setup/link-with-zotero.mdx +++ b/website/docs/initial-setup/link-with-zotero.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 3 --- # Link With Zotero diff --git a/website/docs/initial-setup/select-an-institution.mdx b/website/docs/initial-setup/select-an-institution.mdx index 86619975..064f11ea 100644 --- a/website/docs/initial-setup/select-an-institution.mdx +++ b/website/docs/initial-setup/select-an-institution.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +sidebar_position: 2 --- # Institutional Access diff --git a/website/static/img/docs/app_setup/sync/pocketbase_create_user.png b/website/static/img/docs/app_setup/sync/pocketbase_create_user.png new file mode 100644 index 00000000..1ab80914 Binary files /dev/null and b/website/static/img/docs/app_setup/sync/pocketbase_create_user.png differ diff --git a/website/static/img/docs/app_setup/sync/pocketbase_table_import.png b/website/static/img/docs/app_setup/sync/pocketbase_table_import.png new file mode 100644 index 00000000..0f606513 Binary files /dev/null and b/website/static/img/docs/app_setup/sync/pocketbase_table_import.png differ diff --git a/windows/wispar_setup.iss b/windows/wispar_setup.iss index aafa4383..5e4b6aa4 100644 --- a/windows/wispar_setup.iss +++ b/windows/wispar_setup.iss @@ -71,20 +71,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] Source: "../build/windows/x64/runner/Release/{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/flutter_inappwebview_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/flutter_local_notifications_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/msvcp140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/msvcp140_1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/msvcp140_2.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/pdfium.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/pdfrx.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/permission_handler_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/vcruntime140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/vcruntime140_1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "../build/windows/x64/runner/Release/WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "../build/windows/x64/runner/Release/*.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "../build/windows/x64/runner/Release/data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons]