diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0dbf8ab24e..6e72830bcc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -4,4 +4,5 @@ github: marcelklehr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g patreon: marcelklehr open_collective: floccus liberapay: marcelklehr +ko_fi: marcelklehr custom: https://www.paypal.me/marcelklehr1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a831a08d53..d83787e21a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,7 +87,7 @@ jobs: matrix: node-version: [20.x] npm-version: [10.x] - server-version: ['27'] + server-version: ['28'] app-version: ['stable'] floccus-adapter: - fake @@ -109,28 +109,28 @@ jobs: - chrome include: - app-version: master - server-version: 27 + server-version: 28 floccus-adapter: nextcloud-bookmarks test-name: test browsers: firefox node-version: 14.x npm-version: 7.x - app-version: master - server-version: 27 + server-version: 28 floccus-adapter: nextcloud-bookmarks test-name: benchmark root browsers: firefox node-version: 14.x npm-version: 7.x - app-version: master - server-version: 27 + server-version: 28 floccus-adapter: nextcloud-bookmarks-old test-name: benchmark root browsers: firefox node-version: 14.x npm-version: 7.x - app-version: stable - server-version: 27 + server-version: 28 floccus-adapter: fake-noCache test-name: test browsers: firefox @@ -258,6 +258,7 @@ jobs: env: SELENIUM_BROWSER: ${{ matrix.browsers }} FLOCCUS_TEST: ${{matrix.floccus-adapter}} ${{ matrix.test-name}} + FLOCCUS_TEST_SEED: ${{ github.sha }} GIST_TOKEN: ${{ secrets.GIST_TOKEN }} GOOGLE_API_REFRESH_TOKEN: ${{ secrets.GOOGLE_API_REFRESH_TOKEN }} APP_VERSION: ${{ matrix.app-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index deacaa35c3..c30ee3bb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [5.0.12] - 2024-04-26 + +### Fixed + - fix(tests/gdrive): Don't derive file name from seed + - chore: Allow fuzzed testing with interrupts on nextcloud-bookmarks + - enh(ci/tests); Use github sha as seed + - fix: Store continuation while sync is running to be able to resume after interrupts + - chore: Update donation methods Marcel Klehr 21.04.24, 20:57 +- fix: Distinguish between InterruptedSyncError and CancelledSyncError +- [android] Include dependenciesInfo in gradle file +- [native] fix(Account): Don't try to load LocalTabs resource + +## [5.0.11] - 2024-03-09 + +### Fixed + +* fix: Android app stuck on splash screen + +## [5.0.10] - 2024-03-08 + +### Fixed + +* fix(Account#sync): Break lock after 2h +* bookmarks folder selection: Select sub folder in Vivaldi + ## [5.0.9] - 2024-01-08 ### Fixed diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 1a5c2c10ca..c5b279b7e6 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -75,7 +75,7 @@ "message": "E025: Die Einstellung der Bookmarks-Datei darf nicht mit einem Schrägstrich beginnen: '/'" }, "Error026": { - "message": "E026: HTTP-Status {0}. Fehler beim Laden der Lesezeichen." + "message": "E026: Der Synchronisierungsprozess wurde abgebrochen" }, "Error027": { "message": "E027: Der Synchronisierungsprozess wurde unterbrochen." @@ -216,7 +216,7 @@ "message": "Cache zurücksetzen" }, "DescriptionResetcache": { - "message": "Setzen SIe diesen Haken, um den Cache zurückzusetzen, sodass die nächste Synchronisierung garantiert keine Lesezeichen löscht, sondern lediglich Server und Lokale Lesezeichen vereinigt." + "message": "Klicken Sie diesen Button, um den Cache zurückzusetzen, sodass die nächste Synchronisierung garantiert keine Lesezeichen löscht, sondern lediglich Server und Lokale Lesezeichen vereinigt." }, "LabelParallelsync": { "message": "Synchronisierung beschleunigen" @@ -351,6 +351,18 @@ "DescriptionGithubsponsors": { "message": "Tätige regelmäßige Spenden über GitHub sponsors um das Projekt zu unterstützen" }, + "LabelPatreon": { + "message": "Patreon" + }, + "DescriptionPatreon": { + "message": "Tätige regelmäßige Spenden über Patreon um das Projekt zu unterstützen." + }, + "LabelKofi": { + "message": "Kofi" + }, + "DescriptionKofi": { + "message": "Tätige regelmäßige oder einmalige Spenden über Kofi um das Projekt zu unterstützen." + }, "LegacyAdapterDeprecation": { "message": "Dieser veraltete Profiltyp wird nicht mehr unterstützt und wird bald entfernt. Bitte wechseln Sie zur neuen Nextcloud-Synchronisierungsmethode. Verbesserte Leistung und Genauigkeit erwarten Sie." }, @@ -508,7 +520,7 @@ "message": "Sende Client-Legitimation" }, "DescriptionClientcert": { - "message": "Schalten Sie diese Option an, wenn Ihr Server ein Client-Zertifikat oder Cookies zur Authentifizierung benötigt. Dies kann unerwünschte Nebeneffekte haben, da Floccus sich Cookies mit Ihrem normalen Browser-Profil teilen wird." + "message": "Schalten Sie diese Option an, wenn Ihr Server ein Client-Zertifikat oder Cookies zur Authentifizierung benötigt. Dies kann unerwünschte Nebeneffekte haben, da Floccus sich Cookies mit Ihren normalen Browser-Sitzungen teilen wird." }, "LabelAllowredirects": { "message": "Erlaube Weiterleitungen in der Server-URL" diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 929bc438e1..3053e31990 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -75,7 +75,7 @@ "message": "E025: Bookmarks file setting mustn't begin with a slash: '/'" }, "Error026": { - "message": "E026: HTTP status {0}. Failed to fetch bookmarks" + "message": "E026: Sync process was cancelled" }, "Error027": { "message": "E027: Sync process was interrupted" @@ -216,7 +216,7 @@ "message": "Reset cache" }, "DescriptionResetcache": { - "message": "Tick this box to reset the cache so that the next synchronization run is guaranteed not to delete any data and merely merges server and local bookmarks together" + "message": "Click this button to reset the cache so that the next synchronization run is guaranteed not to delete any data and merely merges server and local bookmarks together" }, "LabelParallelsync": { "message": "Speed up synchronization" @@ -351,6 +351,18 @@ "DescriptionGithubsponsors": { "message": "Make a regular donation via GitHub sponsors to support the project" }, + "LabelPatreon": { + "message": "Patreon" + }, + "DescriptionPatreon": { + "message": "Make a regular donation via Patreon to support the project" + }, + "LabelKofi": { + "message": "Kofi" + }, + "DescriptionKofi": { + "message": "Make a regular or one-time donation via Ko-fi to support the project" + }, "LegacyAdapterDeprecation": { "message": "This legacy profile type is deprecated and will soon be removed. Please switch to the new nextcloud sync method. Improved performance and accuracy await you." }, @@ -508,7 +520,7 @@ "message": "Send client credentials" }, "DescriptionClientcert": { - "message": "Enable this option if your server requires a client certificate or cookies for authentication. This may cause unintended side effects, as floccus will share cookies with your normal browser profile." + "message": "Enable this option if your server requires a client certificate or cookies for authentication. This may cause unintended side effects, as floccus will share cookies with your normal browser sessions." }, "LabelAllowredirects": { "message": "Allow redirects in Server URL" diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index fe0ca9f1e8..fbaa55082a 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -90,7 +90,7 @@ "message": "E030: Le déchiffrement de votre fichier de signets a échoué. Le mot de passe est peut être erronée ou le fichier corrompu." }, "Error031": { - "message": "E031: N'a pas pu s'authentifier avec Google Drive. Connectez de nouveau floccus à votre compte google." + "message": "E031: Impossible de s'authentifier avec Google Drive. Connectez de nouveau floccus à votre compte Google." }, "Error032": { "message": "E032: Erreur OAuth. Erreur de validation du token. Merci de vous reconnecter à votre compte Google." @@ -99,11 +99,14 @@ "message": "E033: Redirection détectée. Vérifiez que le serveur supporte la méthode de synchronisation choisie et que l'URL entrée est correcte et ne redirige pas vers une autre destination. Si la redirection fait partie de votre configuration, vous pouvez désactivez cette vérification dans les paramètres." }, "Error034": { - "message": "E034: Le fichier de signets est illisible. Avez-vous oublié d'entrer un mot de passe de chiffrement ?" + "message": "E034: Le fichier de signets distant est illisible. Avez-vous oublié d'entrer un mot de passe de chiffrement ?" }, "Error035": { "message": "E035 : Échec de la création du signet suivant sur le serveur : {0}" }, + "Error036": { + "message": "E036: Permission manquante pour accéder au serveur de synchronisation" + }, "LabelWebdavurl": { "message": "URL WebDAV" }, @@ -132,13 +135,19 @@ "message": "Dossier serveur" }, "DescriptionServerfolder": { - "message": "Ceci est le préfixe du chemin d'accès qu'utilisera votre compte sur le serveur. Laisser vide pour ne pas utiliser de préfixe." + "message": "Lors de la synchronisation, vos signets seront stockés sous ce chemin d'accès sur le serveur. Ce chemin représente un dossier dans l'application Nextcloud Bookmarks, et non un dossier dans NextCloud Files. Laisser vide pour stocker tous les liens dans le dossier racine du serveur." + }, + "LabelLocaltarget": { + "message": "Dossier local" + }, + "DescriptionLocaltarget": { + "message": "Choisissez ici si vous désirez synchroniser les signets ou les onglets de votre navigateur." }, "LabelLocalfolder": { - "message": "Répertoire local" + "message": "Dossier de signets" }, "DescriptionLocalfolder": { - "message": "Il s'agit du répertoire local de signets du navigateur, qui sera synchronisé sur le serveur. Notez que certains navigateurs n'autorisent pas le création de nouveaux éléments dans le dossier racine (par exemple Firefox et Google Chrome)" + "message": "Les signets dans ce dossiers seront stockés en tant que liens sur le serveurs. Les liens sur le serveur seront stockés comme des signets dans ce dossier sur ce navigateur. " }, "LabelRootfolder": { "message": "Répertoire racine" @@ -155,6 +164,9 @@ "LabelCancelsync": { "message": "Annuler synchronisation" }, + "LabelSyncall": { + "message": "Synchroniser tous les profils" + }, "LabelAutosync": { "message": "Automatique" }, @@ -176,6 +188,9 @@ "StatusSyncing": { "message": "Synchronisation" }, + "StatusScheduled": { + "message": "Planifié" + }, "LabelReset": { "message": "Réinitialisation" }, @@ -189,10 +204,10 @@ "message": "Définir un répertoire existant à synchroniser" }, "LabelRemoveaccount": { - "message": "Supprimer le compte" + "message": "Supprimer le profil" }, "DescriptionRemoveaccount": { - "message": "Supprimer ce compte (cela ne supprimera pas vos signets)" + "message": "Supprimer ce profil (cela ne supprimera pas vos signets)" }, "LabelSyncfromscratch": { "message": "Réinitialiser la synchronisation" @@ -246,13 +261,13 @@ "message": "Nextcloud Bookmarks" }, "DescriptionAdapternextcloudfolders": { - "message": "Cette option est compatible à partir de la version 0.14 de l'application Bookmarks. Elle ne peut synchroniser que les signets \"http\" et \"ftp\"." + "message": "L'option \"Signets Nextcloud\" synchronise vos signets avec l'application Bookmark de Nextcloud. Elle ne peut synchroniser que les signets \"http\" et \"ftp\". Assurez-vous d'avoir installé l'application Bookmark de l'App Store Nextcloud dans votre espace Nextcloud." }, "LabelAdapternextcloud": { "message": "Nextcloud Bookmarks (ancienne méthode)" }, "DescriptionAdapternextcloud": { - "message": "Cette ancienne option n'est compatible qu'à partir de la version 0.11 de l'application Bookmarks. Elle émulera des dossiers en utilisant des tags contenant le chemin du dossier. Cette option n'est pas recommandée pour les nouveaux comptes." + "message": "Cette ancienne option n'est compatible qu'à partir de la version 0.11 de l'application Bookmarks. Elle émulera des dossiers en utilisant des tags contenant le chemin du dossier. Cette option n'est pas recommandée pour les nouveaux profils." }, "LabelAdapterwebdav": { "message": "Partage WebDAV" @@ -261,13 +276,7 @@ "message": "L'option WebDAV synchronise vos signets en les conservant dans un fichier situé dans le partage WebDAV. Il n'y a pas d'interface Web accompagnant cette option, et vous pouvez l'utiliser avec n'importe quel serveur compatible WebDAV. Elle permet de synchroniser des signets HTTP, FTP, données, fichier et JavaScript." }, "LabelAddaccount": { - "message": "Ajouter un compte" - }, - "LabelSecurecredentials": { - "message": "Verrouiller Floccus" - }, - "LabelSecuredcredentials": { - "message": "Verrouillage configuré" + "message": "Ajouter un profil" }, "LabelOpenintab": { "message": "Ouvrir dans un onglet" @@ -287,12 +296,6 @@ "LabelUntitledfolder": { "message": "Dossier sans nom" }, - "LabelSetkey": { - "message": "Configurer un mot de passe pour floccus" - }, - "DescriptionSetkey": { - "message": "Si vous configurez un mot de passe, vous devrez rentrer ce mot de passe à chaque démarrage de votre navigateur si vous voulez accéder à floccus pour lancer une synchronisation de vos signets, changer vos paramètres ou n'importe quoi d'autre." - }, "LabelSetkeybutton": { "message": "Configurer mot de passe" }, @@ -322,7 +325,7 @@ }, "LabelOptionsscreen": { "message": "Options {0}", - "description": "Title of the options screen. The placeholder holds the account type." + "description": "Title of the options screen. The placeholder holds the profile type." }, "LabelPaypal": { "message": "Paypal" @@ -349,7 +352,7 @@ "message": "Faire un don récurrent en devenant parrain du projet sur GitHub" }, "LegacyAdapterDeprecation": { - "message": "Cet ancien type de compte n'est plus supporté et sera bientôt supprimé. Merci de le remplacer par la nouvelle méthode de synchronisation Nextcloud. Des performances accrues vous attendent." + "message": "Cet ancien type de profil n'est plus supporté et sera bientôt supprimé. Merci de le remplacer par la nouvelle méthode de synchronisation Nextcloud. Elle vous apportera des performances accrues." }, "LabelUpdated": { "message": "Floccus a été mis à jour" @@ -373,16 +376,16 @@ "message": "Actions risquées" }, "LabelAccountDeleted": { - "message": "Compte supprimé" + "message": "Profil supprimé" }, "DescriptionAccountDeleted": { - "message": "Ce compte a été supprimé" + "message": "Ce profil a été supprimé" }, "LabelNoAccount": { - "message": "Il n'y a pas de comptes ici" + "message": "Il n'y a pas de profil ici" }, "DescriptionNoAccount": { - "message": "Créez un nouveau compte pour synchroniser vos signets ou importez un compte depuis un autre appareil ou navigateur." + "message": "Créez un nouveau profil pour synchroniser vos signets ou importez un compte depuis un autre appareil ou navigateur." }, "LabelLoginFlowStart": { "message": "Se connecter avec Nextcloud" @@ -394,40 +397,43 @@ "message": "La connexion avec Nextcloud a échouée" }, "LabelNewAccount": { - "message": "Nouveau Compte" + "message": "Nouveau Profil" }, "LabelNestedSync": { - "message": "Comptes emboîtés" + "message": "Profils emboîtés" }, "DescriptionNestedSync": { - "message": "Vous pouvez emboîter des comptes de telle manière qu'un dossier appartienne à un compte A et un de ses sous-dossier aux comptes A et B. Voulez-vous autoriser d'autres comptes à se synchroniser avec les dossiers du compte actuel ?" + "message": "Vous pouvez emboîter des profils de telle manière qu'un dossier appartienne à un profil A et un de ses sous-dossier aux profils A et B. Voulez-vous autoriser d'autres profils à se synchroniser avec les dossiers du profil actuel ?" }, "LabelNestedSyncNo": { - "message": "Non, ignorer le dossier de ce compte pour d'autres comptes" + "message": "Non, ignorer le dossier de ce profil pour d'autres profils" }, "LabelNestedSyncYes": { - "message": "Oui, inclure le dossier de ce compte dans d'autres comptes" + "message": "Oui, inclure le dossier de ce profil dans d'autres profils" }, "LabelImportExport": { - "message": "Importer/Exporter un Compte" + "message": "Importer/Exporter un Profil" }, "LabelExport": { - "message": "Exporter des comptes" + "message": "Exporter des profils" }, "LabelImport": { - "message": "Importer des comptes" + "message": "Importer des profils" }, "DescriptionExport": { - "message": "Choisissez les comptes ci-dessous que vous souhaitez exporter dans un fichier. Ainsi vous pourrez facilement recréer le même compte dans un autre appareil ou navigateur." + "message": "Choisissez les profils ci-dessous que vous souhaitez exporter dans un fichier. Ainsi vous pourrez facilement recréer le même profil dans un autre appareil ou navigateur." }, "DescriptionImport": { - "message": "Importez ici un fichier exporté depuis un autre appareil ou navigateur, qui contient les données de votre compte. Assurez-vous de configurer le bon dossier à synchroniser une fois l'importation réalisée." + "message": "Importez ici un fichier exporté depuis un autre appareil ou navigateur, qui contient les données de votre profil. Assurez-vous de configurer le bon dossier à synchroniser une fois l'importation réalisée." }, "LabelFolderNotFound": { "message": "Dossier non trouvé" }, "LabelSyncTabs": { - "message": "Synchroniser les onglets" + "message": "Onglets du navigateur" + }, + "DescriptionSyncTabs": { + "message": "Les liens qui sont stockés sur le serveur seront ouverts en tant qu'onglets dans votre navigateur. Les onglets ouverts sur votre navigateur seront stockés sur votre server. Etant donné que les liens sur le serveur seront tous ouverts comme des onglets sur votre navigateur lors de la prochaine synchronisation, votre navigateur pourrait être submergé." }, "LabelTabs": { "message": "Onglets" @@ -475,7 +481,7 @@ "message": "Désactivée. Permet la suppression de plus de 50% de vos signets locaux sans vous en demander l'autorisation." }, "StatusFailsafeoff": { - "message": "Sécurité désactivée. Il y a un risque de perte de données involontaire. Il est recommendé de laisser active la Protection de secours dans les paramètres de votre compte." + "message": "Sécurité désactivée. Il y a un risque de perte de données involontaire. Il est recommendé de laisser active la Protection de secours dans les paramètres de votre profil." }, "LabelAdaptergoogledrive": { "message": "Google Drive" @@ -628,7 +634,10 @@ "message": "Paramètres de synchronisation" }, "LabelAccountcreated": { - "message": "Compte créé" + "message": "Profil créé" + }, + "DescriptionAccountcreated": { + "message": "Votre profil a été créé. Vous pouvez à présent fermer cet onglet." }, "DescriptionNonhttps": { "message": "Vous accédez à un serveur qui utilise un protocole non sécurisé. Il est recommandé de n'utiliser que des serveurs compatibles avec HTTPS" @@ -655,9 +664,18 @@ "message": "Exporter les signets" }, "DescriptionExportBookmarks" : { - "message": "Vous pouvez exporter tous les signets de ce compte comme fichier HTML compatible avec tous les principaux navigateurs." + "message": "Vous pouvez exporter tous les signets de ce profil comme fichier HTML compatible avec tous les principaux navigateurs." }, "LabelShareitem": { "message": "Partager" + }, + "LabelImportsuccessful": { + "message": "Profil(s) importé(s) avec succès." + }, + "DescriptionSyncinprogress": { + "message": "Synchronisation en cours" + }, + "DescriptionSyncscheduled": { + "message": "Ce profil sera bientôt synchronisé. Nous attendons que vos autres appareils ou vos autres profils sur cet appareil aient terminé leur synchronisation." } } diff --git a/_locales/gl/messages.json b/_locales/gl/messages.json index 22fc96aba7..442ea1c204 100644 --- a/_locales/gl/messages.json +++ b/_locales/gl/messages.json @@ -75,7 +75,7 @@ "message": "E025: A configuración do ficheiro de marcadores non debe comezar cunha barra: «/»" }, "Error026": { - "message": "E026: Estado HTTP {0}. Produciuse un erro ao recuperar os marcadores" + "message": "E026: O proceso de sincronización foi cancelado" }, "Error027": { "message": "E027: O proceso de sincronización foi interrompido" @@ -216,7 +216,7 @@ "message": "Restabelecer a caché" }, "DescriptionResetcache": { - "message": "Marque esta caixa para restabelecer a caché para que a seguinte execución de sincronización non elimine ningún dato e só combine os marcadores locais e o servidor." + "message": "Prema neste botón para restabelecer a caché para que a próxima sincronización non borre ningún dato e se limite a combinar os marcadores locais e do servidor." }, "LabelParallelsync": { "message": "Acelerar a sincronización" @@ -351,6 +351,18 @@ "DescriptionGithubsponsors": { "message": "Faga unha doazón regular a través dos patrocinadores de GitHub para colaborar co proxecto" }, + "LabelPatreon": { + "message": "Patreon" + }, + "DescriptionPatreon": { + "message": "Faga unha doazón periódica a través de Patreon para colaborar co proxecto" + }, + "LabelKofi": { + "message": "Kofi" + }, + "DescriptionKofi": { + "message": "Faga unha doazón periódica ou unha puntual a través de Ko-fi para colaborar co proxecto" + }, "LegacyAdapterDeprecation": { "message": "Este tipo de perfil herdado está en desuso e vai ser eliminado en pouco tempo. Cambie ao novo método de sincronización de Nextcloud. Agárdanlle melloras de rendemento e precisión." }, @@ -508,7 +520,7 @@ "message": "Enviar as credenciais do cliente" }, "DescriptionClientcert": { - "message": "Active esta opción se o seu servidor require un certificado de cliente ou cookies para a autenticación. Isto pode causar efectos secundarios non desexados, xa que Floccus compartirá cookies co seu perfil de navegador normal." + "message": "Active esta opción se o seu servidor require un certificado de cliente ou cookies para a autenticación. Isto pode causar efectos secundarios non desexados, xa que Floccus compartirá cookies coas sesións normais do seu navegador." }, "LabelAllowredirects": { "message": "Permitir redireccións no URL do servidor" diff --git a/_locales/nl_NL/messages.json b/_locales/nl_NL/messages.json new file mode 100644 index 0000000000..35c7b9c873 --- /dev/null +++ b/_locales/nl_NL/messages.json @@ -0,0 +1,693 @@ +{ + "Error001": { + "message": "001: Map om in te maken bestaat niet" + }, + "Error002": { + "message": "E002: Bladwijzer om bij te werken bestaat niet meer" + }, + "Error003": { + "message": "E003: Map om uit te verwijderen bestaat niet. Dit is een fout. Gefeliciteerd." + }, + "Error004": { + "message": "E004: Map waarnaar verplaatst moet worden bestaat niet" + }, + "Error005": { + "message": "E005: Map waarin aangemaakt moet worden bestaat niet" + }, + "Error006": { + "message": "E006: Map om bij te werken bestaat niet" + }, + "Error007": { + "message": "E007: Map om te verplaatsen bestaat niet" + }, + "Error008": { + "message": "E008: Map om uit te verwijderen bestaat niet" + }, + "Error009": { + "message": "E009: Map waarnaar verplaatst moet worden bestaat niet" + }, + "Error010": { + "message": "E010: Kon de te ordenen map niet vinden" + }, + "Error011": { + "message": "E011: Item in map volgorde is geen echt kind: {0}" + }, + "Error012": { + "message": "E012: Bij het ordenen van mappen ontbreken enkele kinderen van de map" + }, + "Error013": { + "message": "E013: Te verwijderen map bestaat niet" + }, + "Error014": { + "message": "E014: Bovenliggende map waaruit map moet worden verwijderd bestaat niet" + }, + "Error015": { + "message": "E015: Onverwacht antwoord van de server" + }, + "Error016": { + "message": "E016: Request timed out. Controleer uw serverconfiguratie" + }, + "Error017": { + "message": "E017: Netwerkfout: Controleer uw netwerkverbinding en uw accountgegevens" + }, + "Error018": { + "message": "E018: Kon niet aanmelden op de server." + }, + "Error019": { + "message": "E019: HTTP-status {0}. {1} verzoek mislukt. Controleer de serverconfiguratie en het logboek." + }, + "Error020": { + "message": "E020: Kon de reactie van de server niet verwerken. Is de Bookmarks-app geïnstalleerd op uw server?" + }, + "Error021": { + "message": "E021: Inconsistente serverstatus. Map is aanwezig in de kindvolgordelijst maar niet in de mappenstructuur" + }, + "Error022": { + "message": "E022: Map {0} bevat waarschijnlijk een niet-bestaande bladwijzer {1}" + }, + "Error023": { + "message": "E023: Kan het vergrendelbestand niet vrijgeven, overweeg {0} handmatig te verwijderen." + }, + "Error024": { + "message": "E024: HTTP-status {0} tijdens het proberen om de status van vergrendelbestand {1} te bepalen" + }, + "Error025": { + "message": "E025: De Bookmarks instelling naar het bladwijzerbestand mag niet beginnen met een schuine streep: '/'" + }, + "Error026": { + "message": "E026: Synchronisatieproces werd afgebroken" + }, + "Error027": { + "message": "E027: Synchronisatieproces werd onderbroken" + }, + "Error028": { + "message": "E028: Kon niet aanmelden op de server." + }, + "Error029": { + "message": "E029: Voorzorgsmaatregel: De huidige synchronisatierun zou {0}% van uw bladwijzers verwijderen. Uitvoeren is gewijgerd. Schakel deze veiligheidsmaatregel uit in de accountinstellingen als u toch wilt doorgaan." + }, + "Error030": { + "message": "E030: Het decoderen van het bladwijzerbestand is mislukt. De wachtwoordzin kan verkeerd zijn of het bestand kan beschadigd zijn." + }, + "Error031": { + "message": "E031: Kon niet aanmelden bij Google Drive. Maak opnieuw verbinding met uw Google-account." + }, + "Error032": { + "message": "E032: OAuth-fout. Fout bij tokenvalidatie. Maak opnieuw verbinding met uw Google-account." + }, + "Error033": { + "message": "E033: Omleiding gedetecteerd. Controleer of de server de geselecteerde synchronisatiemethode ondersteunt en of de URL die je hebt ingevoerd correct is en niet wordt omgeleid naar een andere locatie. Als de omleiding deel uitmaakt van je instellingen, kun je deze controle in de instellingen uitschakelen." + }, + "Error034": { + "message": "E034: Het externe bladwijzerbestand is onleesbaar. Bent u vergeten een coderingswachtzin in te stellen?" + }, + "Error035": { + "message": "E035: Het aanmaken van de volgende bladwijzer op de server is mislukt: {0}" + }, + "Error036": { + "message": "E036: Ontbrekende rechten voor toegang tot de synchronisatieserver" + }, + "LabelWebdavurl": { + "message": "WebDAV URL" + }, + "DescriptionWebdavurl": { + "message": "bijv. met nextcloud: https://example.com/remote.php/webdav/" + }, + "LabelNextcloudurl": { + "message": "Nextcloud URL" + }, + "LabelUsername": { + "message": "Gebruikersnaam" + }, + "LabelPassword": { + "message": "Wachtwoord" + }, + "LabelBookmarksfile": { + "message": "Pad naar bladwijzerbestand" + }, + "DescriptionBookmarksfile": { + "message": "een pad naar het bladwijzerbestand relatief aan uw WebDAV URL (alle mappen in het pad moeten al bestaan). bijv. personal_stuff/bookmarks.xbel" + }, + "DescriptionBookmarksfilegoogle": { + "message": "de bestandsnaam van het bladwijzerbestand in je Google Drive (zorg ervoor dat deze naam uniek is in je Drive)" + }, + "LabelServerfolder": { + "message": "Server doel" + }, + "DescriptionServerfolder": { + "message": "Bij het synchroniseren worden uw bladwijzers in deze browser opgeslagen als links onder dit pad op de server. Dit pad vertegenwoordigt een map in de Nextcloud Bladwijzers-app, geen map in Nextcloud Bestanden. Laat dit leeg om alle links in de bovenste map op de server te plaatsen." + }, + "LabelLocaltarget": { + "message": "Lokaal doel" + }, + "DescriptionLocaltarget": { + "message": "Kies hier of je browserbladwijzers of browsertabbladen wilt synchroniseren." + }, + "LabelLocalfolder": { + "message": "Bladwijzer map" + }, + "DescriptionLocalfolder": { + "message": "Bladwijzers in deze bladwijzermap worden opgeslagen als links op de server en links op de server worden opgeslagen als bladwijzers in deze bladwijzermap in deze browser." + }, + "LabelRootfolder": { + "message": "Basismap" + }, + "LabelNewfolder": { + "message": "Nieuw aangemaakte map" + }, + "LabelOptions": { + "message": "Opties" + }, + "LabelSyncnow": { + "message": "Nu synchroniseren" + }, + "LabelCancelsync": { + "message": "Synchronisatie afbreken" + }, + "LabelSyncall": { + "message": "Synchroniseer alle profielen" + }, + "LabelAutosync": { + "message": "Autosync" + }, + "StatusLastsynced": { + "message": "Laatst gesynchroniseerd: {0} geleden" + }, + "StatusNeversynced": { + "message": "Nog niet gesynchroniseerd" + }, + "StatusAllgood": { + "message": "Alles in orde" + }, + "StatusDisabled": { + "message": "Uitgeschakeld" + }, + "StatusError": { + "message": "Fout" + }, + "StatusSyncing": { + "message": "Synchroniseren" + }, + "StatusScheduled": { + "message": "Gepland" + }, + "LabelReset": { + "message": "Opnieuw instellen" + }, + "DescriptionReset": { + "message": "Gesynchroniseerde map opnieuw instellen om een nieuwe aan te maken" + }, + "LabelChoosefolder": { + "message": "Kies een map" + }, + "DescriptionChoosefolder": { + "message": "Stel een bestaande map in om te synchroniseren" + }, + "LabelRemoveaccount": { + "message": "Profiel verwijderen" + }, + "DescriptionRemoveaccount": { + "message": "Verwijder dit profiel (hierdoor worden je bladwijzers niet verwijderd)" + }, + "LabelSyncfromscratch": { + "message": "Start synchronisatie vanaf het begin" + }, + "LabelResetCache": { + "message": "Cache resetten" + }, + "DescriptionResetcache": { + "message": "Klik op deze knop om de cache te resetten zodat de volgende synchronisatierun gegarandeerd geen gegevens verwijdert en alleen de bladwijzers van de server en de lokale bladwijzers samenvoegt." + }, + "LabelParallelsync": { + "message": "Synchronisatie versnellen" + }, + "DescriptionParallelsync": { + "message": "Vink dit selectievakje aan om meerdere mappen parallel te verwerken om zo de synchronisatie te versnellen. Deze functie is experimenteel en maakt het moeilijker om de debuglogs te lezen." + }, + "LabelStrategy": { + "message": "Synchronisatiestrategie" + }, + "DescriptionStrategy": { + "message": "Deze optie bepaalt hoe de bladwijzers op verschillende apparaten worden gesynchroniseerd. Meestal wil je wijzigingen van alle kanten behouden als ze met elkaar in overeenstemming zijn, maar soms wil je wijzigingen overschrijven (inclusief toevoegingen en verwijderingen) van andere browsers, of wijzigingen die je lokaal hebt gemaakt overschrijven." + }, + "LabelStrategydefault": { + "message": "Voeg altijd lokale wijzigingen samen met wijzigingen van andere browsers (aanbevolen)" + }, + "LabelStrategyslave": { + "message": "Maak lokale wijzigingen altijd ongedaan en download wijzigingen van andere browsers" + }, + "LabelStrategyoverwrite": { + "message": "Upload altijd lokale wijzigingen en maak wijzigingen van andere browsers ongedaan" + }, + "LabelSave": { + "message": "Opslaan" + }, + "LabelCancel": { + "message": "Annuleren" + }, + "LabelAdd": { + "message": "Toevoegen" + }, + "LabelChange": { + "message": "Wijzig" + }, + "LabelRemove": { + "message": "Verwijder" + }, + "LabelBack": { + "message": "Terug" + }, + "LabelAdapternextcloudfolders": { + "message": "Nextcloud Bookmarks" + }, + "DescriptionAdapternextcloudfolders": { + "message": "De optie 'Nextcloud Bookmarks' synchroniseert uw bladwijzers met de bladwijzer-app voor Nextcloud. Het kan alleen http- en ftp-bladwijzers synchroniseren. Zorg ervoor dat u de bladwijzer-app uit de Nextcloud app store in uw Nextcloud hebt geïnstalleerd." + }, + "LabelAdapternextcloud": { + "message": "extcloud Bookmarks (legacy)" + }, + "DescriptionAdapternextcloud": { + "message": "De legacy-optie is compatibel met ten minste versie v0.11 van de Bladwijzers-app. Het zal mappen emuleren met behulp van tags die het pad van de map bevatten. Het is niet aanbevolen om deze optie te gebruiken voor nieuwe profielen." + }, + "LabelAdapterwebdav": { + "message": "WebDAV netwerklocatie" + }, + "DescriptionAdapterwebdav": { + "message": "De WebDAV optie synchroniseert je bladwijzers door ze op te slaan in een bestand op de aangegeven WebDAV netwerklocatie. Er is geen bijbehorende web UI voor deze optie en je kunt het gebruiken met elke WebDAV-compatibele server. Het kan http, ftp, data, bestand en javascript bladwijzers synchroniseren." + }, + "LabelAddaccount": { + "message": "Profiel toevoegen" + }, + "LabelOpenintab": { + "message": "Openen in tabblad" + }, + "LabelDebuglogs": { + "message": "Debug logs" + }, + "LabelFunddevelopment": { + "message": "Ontwikkeling ondersteunen" + }, + "DescriptionFunddevelopment": { + "message": "Het werk aan floccus wordt gevoed door een vrijwillig abonnementsmodel. Als u denkt dat wat ik doe de moeite waard is en als u elke maand wat geld kunt missen, steun dan alstublieft mijn werk. Overweeg daarnaast ook om floccus een waardering te geven in de browser Extensieswinkel van uw keuze. Hartelijk dank!💙" + }, + "LabelSelect": { + "message": "Selecteer" + }, + "LabelUntitledfolder": { + "message": "Naamloze map" + }, + "LabelSetkeybutton": { + "message": "Wachtwoordzin instellen" + }, + "LabelKey": { + "message": "Voer je ontgrendel wachtwoordzin in" + }, + "LabelKey2": { + "message": "Voer je wachtwoordzin nogmaals in" + }, + "LabelUnlock": { + "message": "Floccus ontgrendelen" + }, + "LabelRemovekey": { + "message": "Verwijder wachtwoordzin" + }, + "LabelRemovedkey": { + "message": "Wachtwoordzin verwijderd" + }, + "LabelSyncinterval": { + "message": "Synchronisatieinterval" + }, + "DescriptionSyncinterval": { + "message": "De tijdspanne tussen twee synchronisaties in minuten. Standaard is 15 minuten." + }, + "LabelChooseadapter": { + "message": "Hoe wil je synchroniseren?" + }, + "LabelOptionsscreen": { + "message": "{0} opties", + "description": "Title of the options screen. The placeholder holds the profile type." + }, + "LabelPaypal": { + "message": "Paypal" + }, + "DescriptionPaypal": { + "message": "Doe een eenmalige donatie via paypal om het project te steunen" + }, + "LabelOpencollective": { + "message": "OpenCollective" + }, + "DescriptionOpencollective": { + "message": "Doneer regelmatig via OpenCollective om het project te steunen" + }, + "LabelLiberapay": { + "message": "Liberapay" + }, + "DescriptionLiberapay": { + "message": "Doneer regelmatig via Librapay om het project te steunen" + }, + "LabelGithubsponsors": { + "message": "GitHub-sponsoren" + }, + "DescriptionGithubsponsors": { + "message": "Doneer regelmatig via GitHub-sponsoren om het project te steunen" + }, + "LabelPatreon": { + "message": "Patreon" + }, + "DescriptionPatreon": { + "message": "Doneer regelmatig via Patreon om het project te steunen" + }, + "LabelKofi": { + "message": "Kofi" + }, + "DescriptionKofi": { + "message": "Doneer regelmatig of eenmalig via Ko-fi om het project te steunen" + }, + "LegacyAdapterDeprecation": { + "message": "Dit oude profieltype is verouderd en wordt binnenkort verwijderd. Schakel over naar de nieuwe nextcloud synchronisatiemethode. Dit zorgt voor verbeterde prestaties en hogere nauwkeurigheid." + }, + "LabelUpdated": { + "message": "Floccus is bijgewerkt" + }, + "DescriptionUpdated": { + "message": "Gefeliciteerd, de nieuwste update van floccus is geïnstalleerd op uw systeem!" + }, + "LabelReleaseNotes": { + "message": "Lees de uitgavenotities" + }, + "LabelOptionsServerDetails": { + "message": "Serverdetails" + }, + "LabelOptionsFolderMapping": { + "message": "Map toewijzing" + }, + "LabelOptionsSyncBehavior": { + "message": "Synchronisatiegedrag" + }, + "LabelOptionsDangerous": { + "message": "Gevaarlijke acties" + }, + "LabelAccountDeleted": { + "message": "Profiel verwijderd" + }, + "DescriptionAccountDeleted": { + "message": "Dit profiel is verwijderd" + }, + "LabelNoAccount": { + "message": "Hier zijn geen profielen" + }, + "DescriptionNoAccount": { + "message": "Maak een nieuw profiel om je bladwijzers te synchroniseren of importeer profielen van een ander apparaat of een andere browser." + }, + "LabelLoginFlowStart": { + "message": "Aanmelden met Nextcloud" + }, + "LabelLoginFlowStop": { + "message": "Aanmelden met Nextcloud afbreken" + }, + "LabelLoginFlowError": { + "message": "Aanmelden met Nextcloud mislukt" + }, + "LabelNewAccount": { + "message": "Nieuw profiel" + }, + "LabelNestedSync": { + "message": "Gekoppelde profielen" + }, + "DescriptionNestedSync": { + "message": "Je kunt profielen koppelen zodat een bovenliggende map bij profiel A hoort en een submap bij profiel A en B. Wil je andere profielen toestaan om de map van dit profiel ook te synchroniseren?" + }, + "LabelNestedSyncNo": { + "message": "Nee, negeer de map van dit profiel in andere profielen" + }, + "LabelNestedSyncYes": { + "message": "Ja, neem de map van dit profiel op in andere profielen" + }, + "LabelImportExport": { + "message": "Profielen importeren/exporteren" + }, + "LabelExport": { + "message": "Profielen exporteren" + }, + "LabelImport": { + "message": "Profielen importeren" + }, + "DescriptionExport": { + "message": "Selecteer hieronder profielen die je wilt exporteren naar een bestand, zodat je dezelfde profielen eenvoudig opnieuw kunt aanmaken op een ander apparaat of in een andere browser." + }, + "DescriptionImport": { + "message": "Importeer hier een bestand met geëxporteerde profielen om profielen die op een ander apparaat of in een andere browser zijn geëxporteerd opnieuw aan te maken. Zorg ervoor dat u de juiste synchronisatiemappen opnieuw instelt na het importeren." + }, + "LabelFolderNotFound": { + "message": "Map niet gevonden" + }, + "LabelSyncTabs": { + "message": "Browser tabbladen" + }, + "DescriptionSyncTabs": { + "message": "Koppelingen die op de server zijn opgeslagen, worden geopend als browsertabbladen in je browser en bestaande open browsertabbladen worden opgeslagen als koppelingen op je server. Houd er rekening mee dat, afhankelijk van hoeveel links er op de server zijn opgeslagen en omdat ze allemaal als tabbladen worden geopend, bij de volgende synchronisatieslag je browser mogelijk overbelast raakt." + }, + "LabelTabs": { + "message": "Tabbladen" + }, + "LabelSyncDown": { + "message": "Ophalen" + }, + "DescriptionSyncDown": { + "message": "Wijzigingen downloaden van andere browsers & lokale wijzigingen overschrijven" + }, + "LabelSyncUp": { + "message": "Versturen" + }, + "DescriptionSyncUp": { + "message": "Lokale wijzigingen uploaden & wijzigingen van andere browsers overschrijven" + }, + "LabelSyncDownOnce": { + "message": "Eenmalig ophalen" + }, + "LabelSyncUpOnce": { + "message": "Eenmalig versturen" + }, + "LabelSyncNormal": { + "message": "Samenvoegen" + }, + "DescriptionSyncNormal": { + "message": "Lokale wijzigingen samenvoegen met wijzigingen van andere browsers" + }, + "DescriptionFilesPermission": { + "message": "Zorg ervoor dat je floccus niet alleen toestemming geeft om de bladwijzer-app te gebruiken, maar ook de Nextcloud-bestanden-app." + }, + "DescriptionExtension": { + "message": "Uw bladwijzers privé synchroniseren tussen browsers en apparaten" + }, + "LabelFailsafe": { + "message": "Voorzorgsmaatregel" + }, + "DescriptionFailsafe": { + "message": "Soms kunnen configuratiefouten of softwarebugs leiden tot het onbedoeld verwijderen van gegevens, die dan verloren gaan. Om dat te voorkomen zal floccus niet meer dan 50% van uw bladwijzers in één keer verwijderen, tenzij u deze beveiliging hier uitschakelt." + }, + "LabelFailsafeon": { + "message": "Ingeschakeld. Verwijder niet meer dan 50% van mijn lokale bladwijzers zonder het me eerst te vragen. (Aanbevolen)" + }, + "LabelFailsafeoff": { + "message": "Uitgeschakeld. Toestaan dat meer dan 50% van mijn lokale bladwijzers worden verwijderd zonder dat dit door mij wordt bevestigd." + }, + "StatusFailsafeoff": { + "message": "Voorzorgsmaatregel uitgeschakeld. U loopt het risico op onbedoeld gegevensverlies. Het wordt aanbevolen om deze maatregel in te schakelen in de profielinstellingen." + }, + "LabelAdaptergoogledrive": { + "message": "Google Drive" + }, + "DescriptionAdaptergoogledrive": { + "message": "Synchroniseer bladwijzers via een versleuteld bestand dat is opgeslagen in je Google Drive. Het kan http-, ftp-, gegevens-, bestands- en javascriptbladwijzers synchroniseren." + }, + "LabelLogingoogle": { + "message": "Met Google aanmelden" + }, + "DescriptionLogingoogle": { + "message": "Maak verbinding met je Google-account om het bladwijzersynchronisatiebestand op te slaan in je Google Drive." + }, + "DescriptionLoggedingoogle": { + "message": "Je hebt je Google-account gekoppeld om het bladwijzersynchronisatiebestand op te slaan in je Google Drive." + }, + "LabelPassphrase": { + "message": "Wachtwoordzin" + }, + "DescriptionPassphrase": { + "message": "Stel een wachtwoordzin in om je bladwijzerbestand te versleutelen, als je geen wachtwoordzin instelt, wordt er geen versleuteling toegepast." + }, + "LabelClientcert": { + "message": "Aanmeldgegevens versturen" + }, + "DescriptionClientcert": { + "message": "Schakel deze optie in als uw server een clientcertificaat of cookies nodig heeft voor authenticatie. Dit kan onbedoelde neveneffecten veroorzaken, omdat floccus cookies deelt met uw normale browsersessies." + }, + "LabelAllowredirects": { + "message": "Omleidingen in server-URL toestaan" + }, + "DescriptionAllowredirects": { + "message": "Schakel deze optie in als u omleidingsfouten krijgt tijdens het synchroniseren, die volgens u onterecht zijn." + }, + "LabelSearch": { + "message": "Bladwijzers doorzoeken" + }, + "LabelSearchfolder": { + "message": "Zoek {0}" + }, + "LabelEdititem": { + "message": "Bewerken" + }, + "LabelDeleteitem": { + "message": "Verwijderen" + }, + "LabelNobookmarks": { + "message": "Hier zijn geen bladwijzers" + }, + "LabelAddbookmark": { + "message": "Bladwijzer toevoegen" + }, + "LabelEditbookmark": { + "message": "Bladwijzer bewerken" + }, + "LabelAddfolder": { + "message": "Map toevoegen" + }, + "LabelEditfolder": { + "message": "Map bewerken" + }, + "LabelSlugline": { + "message": "Synchronisatie van privébladwijzers" + }, + "LabelDownloadlogs": { + "message": "Logboeken downloaden" + }, + "LabelDownloadfulllogs": { + "message": "Volledige logboeken" + }, + "LabelDownloadanonymizedlogs": { + "message": "Geredigeerde logboeken" + }, + "DescriptionDownloadlogs": { + "message": "Floccus logt al zijn acties in een logbestand dat u zelf kunt bekijken of naar de ontwikkelaars kunt sturen voor debugging-doeleinden. In bewerkte logbestanden worden map- en bladwijzernamen en URL's onomkeerbaar gecodeerd met een cryptografische hashing-functie. Wanneer je geredigeerde logbestanden verstuurt, zorg er dan voor dat je het gedownloade bestand nog steeds controleert op gevoelige gegevens die mogelijk niet zijn opgevangen door het anonimiseringsproces." + }, + "ErrorFolderloopselected": { + "message": "Kan een map niet naar zichzelf verplaatsen" + }, + "ErrorNofolderselected": { + "message": "Er is geen map geselecteerd" + }, + "LabelAllownetwork": { + "message": "Sta netwerkgebruik toe" + }, + "DescriptionAllownetwork": { + "message": "Floccus kan het netwerk gebruiken, naast het verbinden met uw synchronisatieserver, om extra informatie over uw bladwijzers te verkrijgen (zoals pictogrammen, etc.). Hier kunt u dit netwerkgebruik inschakelen." + }, + "LabelMobilesettings": { + "message": "Mobiele instellingen" + }, + "LabelContinuefloccus":{ + "message": "Doorgaan naar floccus" + }, + "LabelAbout":{ + "message": "Over" + }, + "LabelCurrentversion": { + "message": "Huidige versie" + }, + "DescriptionCurrentversion": { + "message": "Uw huidige geïnstalleerde versie van floccus is:" + }, + "LabelContributors": { + "message": "Bijdragen" + }, + "DescriptionContributors": { + "message": "Deze mensen hebben geholpen floccus mogelijk te maken" + }, + "LabelSortcustom": { + "message": "Aangepast" + }, + "LabelSorturl": { + "message": "Koppeling" + }, + "LabelSorttitle": { + "message": "Titel" + }, + "LabelSyncmethod": { + "message": "Synchronisatiemethode" + }, + "LabelSyncserver": { + "message": "Synchronisatieserver" + }, + "LabelSyncfolders": { + "message": "Mapsynchronisatie" + }, + "LabelSyncbehavior": { + "message": "Synchronisatiegedrag" + }, + "LabelContinue": { + "message": "Doorgaan" + }, + "LabelDone": { + "message": "Gereed" + }, + "LabelConnect": { + "message": "Verbinden" + }, + "LabelServersetup": { + "message": "Met welke server wil je synchroniseren?" + }, + "LabelGoogledrivesetup": { + "message": "Aanmelden bij Google Drive" + }, + "LabelSyncfoldersetup": { + "message": "Welke mappen wil je synchroniseren?" + }, + "LabelSyncbehaviorsetup": { + "message": "Hoe wil je dat synchroniseren werkt?" + }, + "LabelAccountcreated": { + "message": "Profiel aangemaakt" + }, + "DescriptionAccountcreated": { + "message": "Je profiel is aangemaakt. Je kunt dit tabblad nu sluiten." + }, + "DescriptionNonhttps": { + "message": "Je hebt een server ingevoerd die een onveilig protocol gebruikt. Het wordt aanbevolen om alleen servers te gebruiken die HTTPS ondersteunen." + }, + "LabelFiletype": { + "message": "Bestandsformaat" + }, + "DescriptionFiletype": { + "message": "Je kunt kiezen welk bestandsformaat je wilt gebruiken om bladwijzers op te slaan in de cloud." + }, + "LabelFiletypehtml": { + "message": "HTML, een breed ondersteund, open formaat (experimenteel)" + }, + "LabelFiletypexbel": { + "message": "XBEL, een eenvoudig, open formaat" + }, + "LabelImportbookmarks": { + "message": "Bladwijzers importeren" + }, + "DescriptionImportbookmarks": { + "message": "Een HTML-bestand met bladwijzers importeren in de huidige map" + }, + "LabelExportBookmarks": { + "message": "Bladwijzers exporteren" + }, + "DescriptionExportBookmarks" : { + "message": "Je kunt alle bladwijzers in dit profiel exporteren als een HTML-bestand dat compatibel is met alle gangbare browsers." + }, + "LabelShareitem": { + "message": "Delen" + }, + "LabelImportsuccessful": { + "message": "Profiel(en) succesvol geïmporteerd" + }, + "DescriptionSyncinprogress": { + "message": "Synchronisatie wordt uitgevoerd." + }, + "DescriptionSyncscheduled": { + "message": "Dit profiel wordt binnenkort gesynchroniseerd. We wachten tot andere apparaten van jou, of andere profielen op dit apparaat, klaar zijn met synchroniseren." + } +} diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e4d71813b..ab0ccd3578 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "org.handmadeideas.floccus" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 5000009 - versionName "5.0.9" + versionCode 5000012 + versionName "5.0.12" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -25,6 +25,12 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } } repositories { diff --git a/android/build.gradle b/android/build.gradle index 394fd1034c..7610899647 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.2.0' + classpath 'com.android.tools.build:gradle:8.3.0' } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index c496901242..a150a9bef5 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Mar 19 14:08:52 CET 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/manifest.chrome.json b/manifest.chrome.json index 27211d6b6e..3a2b419eb5 100644 --- a/manifest.chrome.json +++ b/manifest.chrome.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "floccus bookmarks sync", "short_name": "floccus", - "version": "5.0.9", + "version": "5.0.12.2", "description": "__MSG_DescriptionExtension__", "icons": { "48": "icons/logo.png", diff --git a/manifest.firefox.json b/manifest.firefox.json index 7e6a1923b8..eabb159704 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "floccus bookmarks sync", "short_name": "floccus", - "version": "5.0.9", + "version": "5.0.12.2", "description": "__MSG_DescriptionExtension__", "icons": { "48": "icons/logo.png", diff --git a/manifest.json b/manifest.json index 27211d6b6e..eabb159704 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { - "manifest_version": 3, + "manifest_version": 2, "name": "floccus bookmarks sync", "short_name": "floccus", - "version": "5.0.9", + "version": "5.0.12.2", "description": "__MSG_DescriptionExtension__", "icons": { "48": "icons/logo.png", @@ -10,23 +10,27 @@ "128": "icons/logo_128.png" }, + "applications": { + "gecko": { + "id": "floccus@handmadeideas.org", + "strict_min_version": "57.0" + } + }, + "default_locale": "en", - "permissions": ["alarms", "bookmarks", "storage", "unlimitedStorage", "tabs", "identity"], - "host_permissions": [ - "*://*/*" - ], - "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self';" - }, + "permissions": ["*://*/*", "alarms", "bookmarks", "storage", "unlimitedStorage", "tabs", "identity"], + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", "options_ui": { "page": "dist/html/options.html", - "browser_style": false + "browser_style": false, + "chrome_style": false }, - "action": { + "browser_action": { "browser_style": false, + "chrome_style": false, "default_icon": { "48": "icons/logo.png" }, @@ -35,6 +39,6 @@ }, "background": { - "service_worker": "dist/js/background-script.js" + "page": "dist/html/background.html" } } diff --git a/package-lock.json b/package-lock.json index 735c531732..6bd8363b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "floccus", - "version": "5.0.9", + "version": "5.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "floccus", - "version": "5.0.9", + "version": "5.0.12", "license": "MPL-2.0", "dependencies": { "@byteowls/capacitor-oauth2": "5.x", diff --git a/package.json b/package.json index 648cde6623..ebd3330fe4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "floccus", - "version": "5.0.9", + "version": "5.0.12", "description": "Sync your bookmarks privately across browsers and devices", "scripts": { "build": "gulp", @@ -116,9 +116,9 @@ "> 0.25%", "last 2 versions and supports es6-generators", "Firefox ESR", + "and_chr >= 60", "not dead", "not bb >= 0", - "not and_chr >= 0", "not and_ff >= 0", "not and_qq >= 0", "not and_uc >= 0", diff --git a/src/errors/Error.ts b/src/errors/Error.ts index 0e4bfcd206..0ef6565276 100644 --- a/src/errors/Error.ts +++ b/src/errors/Error.ts @@ -222,12 +222,15 @@ export class SlashError extends FloccusError { } } -// code 26 is unused +export class CancelledSyncError extends FloccusError { + constructor() { + super('E027: Sync process was cancelled') + this.code = 26 + Object.setPrototypeOf(this, InterruptedSyncError.prototype) + } +} export class InterruptedSyncError extends FloccusError { - public status: number - public lockFile: string - constructor() { super('E027: Sync process was interrupted') this.code = 27 diff --git a/src/lib/Account.ts b/src/lib/Account.ts index 0a52213e25..defb3ec84a 100644 --- a/src/lib/Account.ts +++ b/src/lib/Account.ts @@ -1,15 +1,17 @@ import AdapterFactory from './AdapterFactory' import Logger from './Logger' -import { Folder, ItemLocation } from './Tree' +import { Folder, ItemLocation, TItemLocation } from './Tree' import UnidirectionalSyncProcess from './strategies/Unidirectional' import MergeSyncProcess from './strategies/Merge' import DefaultSyncProcess from './strategies/Default' import IAccountStorage, { IAccountData, TAccountStrategy } from './interfaces/AccountStorage' import { TAdapter } from './interfaces/Adapter' -import { IResource, TLocalTree } from './interfaces/Resource' +import { OrderFolderResource, TLocalTree } from './interfaces/Resource' import { Capacitor } from '@capacitor/core' import IAccount from './interfaces/Account' import Mappings from './Mappings' +import { isTest } from './isTest' +import CachingAdapter from './adapters/Caching' // register Adapters AdapterFactory.register('nextcloud-folders', async() => (await import('./adapters/NextcloudBookmarks')).default) @@ -19,6 +21,9 @@ AdapterFactory.register('git', async() => (await import('./adapters/Git')).defau AdapterFactory.register('google-drive', async() => (await import('./adapters/GoogleDrive')).default) AdapterFactory.register('fake', async() => (await import('./adapters/Fake')).default) +// 2h +const LOCK_TIMEOUT = 1000 * 60 * 60 * 2 + export default class Account { static cache = {} static singleton : IAccount @@ -100,14 +105,8 @@ export default class Account { return data } - async getResource():Promise { - if (this.getData().localRoot !== 'tabs') { - return this.localTree - } else { - const LocalTabs = (await import('./LocalTabs')).default - this.localTabs = new LocalTabs(this.storage) - return this.localTabs - } + async getResource():Promise { + return this.localTree } async setData(data:IAccountData):Promise { @@ -145,6 +144,9 @@ export default class Account { try { if (this.getData().syncing || this.syncing) return + const localResource = await this.getResource() + if (!(await this.server.isAvailable()) || !(await localResource.isAvailable())) return + Logger.log('Starting sync process for account ' + this.getLabel()) this.syncing = true await this.setData({ ...this.getData(), syncing: 0.05, scheduled: false, error: null }) @@ -153,15 +155,6 @@ export default class Account { await this.init() } - let localResource - if (this.getData().localRoot !== 'tabs') { - localResource = this.localTree - } else { - const LocalTabs = (await import('./LocalTabs')).default - this.localTabs = new LocalTabs(this.storage) - localResource = this.localTabs - } - if (this.server.onSyncStart) { const needLock = (strategy || this.getData().strategy) !== 'slave' let status @@ -170,13 +163,25 @@ export default class Account { } catch (e) { // Resource locked if (e.code === 37) { - await this.setData({ ...this.getData(), error: null, syncing: false, scheduled: strategy || this.getData().strategy }) - this.syncing = false - Logger.log( - 'Resource is locked, trying again soon' - ) - await Logger.persist() - return + // We got a resource locked error + if (this.getData().lastSync < Date.now() - LOCK_TIMEOUT) { + // but if we've been waiting for the lock for more than 2h + // start again without locking the resource + status = await this.server.onSyncStart(false) + } else { + await this.setData({ + ...this.getData(), + error: null, + syncing: false, + scheduled: strategy || this.getData().strategy + }) + this.syncing = false + Logger.log( + 'Resource is locked, trying again soon' + ) + await Logger.persist() + return + } } else { throw e } @@ -190,42 +195,83 @@ export default class Account { mappings = await this.storage.getMappings() const cacheTree = localResource.constructor.name !== 'LocalTabs' ? await this.storage.getCache() : new Folder({title: '', id: 'tabs', location: ItemLocation.LOCAL}) - let strategyClass, direction - switch (strategy || this.getData().strategy) { - case 'slave': - Logger.log('Using "merge slave" strategy (no cache available)') - strategyClass = UnidirectionalSyncProcess - direction = ItemLocation.LOCAL - break - case 'overwrite': - Logger.log('Using "merge overwrite" strategy (no cache available)') - strategyClass = UnidirectionalSyncProcess - direction = ItemLocation.SERVER - break - default: - if (!cacheTree.children.length) { - Logger.log('Using "merge default" strategy (no cache available)') - strategyClass = MergeSyncProcess - } else { - Logger.log('Using "default" strategy') - strategyClass = DefaultSyncProcess - } - break + let continuation = await this.storage.getCurrentContinuation() + + if (typeof continuation !== 'undefined' && continuation !== null) { + try { + this.syncProcess = await DefaultSyncProcess.fromJSON( + mappings, + localResource, + this.server, + async (progress) => { + if (!this.syncing) { + return + } + if (!(this.server instanceof CachingAdapter) || !('onSyncComplete' in this.server)) { + await this.storage.setCurrentContinuation(this.syncProcess.toJSON()) + } + await this.setData({ ...this.getData(), syncing: progress }) + await mappings.persist() + }, + continuation + ) + } catch (e) { + continuation = null + Logger.log('Failed to load pending continuation. Continuing with normal sync') + } } - this.syncProcess = new strategyClass( - mappings, - localResource, - cacheTree, - this.server, - (progress) => { - this.setData({ ...this.getData(), syncing: progress }) + if (typeof continuation === 'undefined' || continuation === null || (typeof strategy !== 'undefined' && continuation.strategy !== strategy) || Date.now() - continuation.createdAt > 1000 * 60 * 30) { + // If there is no pending continuation, we just sync normally + // Same if the pending continuation was overridden by a different strategy + // same if the continuation is older than half an hour. We don't want old zombie continuations + + let strategyClass: typeof DefaultSyncProcess|typeof MergeSyncProcess|typeof UnidirectionalSyncProcess, direction: TItemLocation + switch (strategy || this.getData().strategy) { + case 'slave': + Logger.log('Using "merge slave" strategy (no cache available)') + strategyClass = UnidirectionalSyncProcess + direction = ItemLocation.LOCAL + break + case 'overwrite': + Logger.log('Using "merge overwrite" strategy (no cache available)') + strategyClass = UnidirectionalSyncProcess + direction = ItemLocation.SERVER + break + default: + if (!cacheTree.children.length) { + Logger.log('Using "merge default" strategy (no cache available)') + strategyClass = MergeSyncProcess + } else { + Logger.log('Using "default" strategy') + strategyClass = DefaultSyncProcess + } + break } - ) - if (direction) { - this.syncProcess.setDirection(direction) + + this.syncProcess = new strategyClass( + mappings, + localResource, + this.server, + async(progress, actionsDone?) => { + await this.setData({ ...this.getData(), syncing: progress }) + if (actionsDone) { + await this.storage.setCurrentContinuation(this.syncProcess.toJSON()) + } + await mappings.persist() + } + ) + this.syncProcess.setCacheTree(cacheTree) + if (direction) { + this.syncProcess.setDirection(direction) + } + await this.syncProcess.sync() + } else { + // if there is a pending continuation, we resume it + + Logger.log('Found existing persisted pending continuation. Resuming last sync') + await this.syncProcess.resumeSync() } - await this.syncProcess.sync() await this.setData({ ...this.getData(), scheduled: false, syncing: 1 }) @@ -247,6 +293,7 @@ export default class Account { this.syncing = false + await this.storage.setCurrentContinuation(null) await this.setData({ ...this.getData(), error: null, @@ -270,6 +317,9 @@ export default class Account { syncing: false, scheduled: false, }) + if (matchAllErrors(e, e => e.code !== 27 && (!isTest || e.code !== 26))) { + await this.storage.setCurrentContinuation(null) + } this.syncing = false if (this.server.onSyncFail) { await this.server.onSyncFail() @@ -277,7 +327,7 @@ export default class Account { // reset cache and mappings after error // (but not after interruption or NetworkError) - if (matchAllErrors(e, e => e.code !== 27 && e.code !== 17)) { + if (matchAllErrors(e, e => e.code !== 27 && e.code !== 17 && (!isTest || e.code !== 26))) { await this.init() } } diff --git a/src/lib/Diff.ts b/src/lib/Diff.ts index b1436649ea..d3149d1d31 100644 --- a/src/lib/Diff.ts +++ b/src/lib/Diff.ts @@ -1,4 +1,4 @@ -import { Folder, TItem, ItemType, TItemLocation, ItemLocation } from './Tree' +import { Folder, TItem, ItemType, TItemLocation, ItemLocation, hydrate } from './Tree' import Mappings, { MappingSnapshot } from './Mappings' import Ordering from './interfaces/Ordering' import batchingToposort from 'batching-toposort' @@ -73,6 +73,15 @@ export default class Diff { } } + clone() { + const newDiff = new Diff + this.getActions().forEach((action: Action) => { + newDiff.commit(action) + }) + + return newDiff + } + commit(action: Action):void { switch (action.type) { case ActionType.CREATE: @@ -261,4 +270,24 @@ export default class Diff { }) return newDiff } + + toJSON() { + return this.getActions().map((action: Action) => { + return { + ...action, + payload: action.payload.clone(false), + oldItem: action.oldItem && action.oldItem.clone(false), + } + }) + } + + static fromJSON(json) { + const diff = new Diff + json.forEach((action: Action): void => { + action.payload = hydrate(action.payload) + action.oldItem = action.oldItem && hydrate(action.oldItem) + diff.commit(action) + }) + return diff + } } diff --git a/src/lib/LocalTabs.ts b/src/lib/LocalTabs.ts index f651c75043..9b0f27e900 100644 --- a/src/lib/LocalTabs.ts +++ b/src/lib/LocalTabs.ts @@ -138,4 +138,11 @@ export default class LocalTabs implements IResource { Logger.log('(tabs)REMOVEFOLDER', id) await this.queue.add(() => browser.tabs.remove(id)) } + + async isAvailable(): Promise { + const tabs = await browser.tabs.query({ + windowType: 'normal' // no devtools or panels or popups + }) + return Boolean(tabs.length) + } } diff --git a/src/lib/Tree.ts b/src/lib/Tree.ts index b4511f248e..e01e0477c1 100644 --- a/src/lib/Tree.ts +++ b/src/lib/Tree.ts @@ -376,3 +376,13 @@ export class Folder { } export type TItem = Bookmark | Folder + +export function hydrate(obj: any) { + if (obj.type === ItemType.FOLDER) { + return Folder.hydrate(obj) + } + if (obj.type === ItemType.BOOKMARK) { + return Bookmark.hydrate(obj) + } + throw new Error(`Cannot hydrate object ${JSON.stringify(obj)}`) +} diff --git a/src/lib/adapters/Caching.ts b/src/lib/adapters/Caching.ts index e7da227b6a..cba376ba82 100644 --- a/src/lib/adapters/Caching.ts +++ b/src/lib/adapters/Caching.ts @@ -4,7 +4,6 @@ import Logger from '../Logger' import Adapter from '../interfaces/Adapter' import difference from 'lodash/difference' -import url from 'url' import Ordering from '../interfaces/Ordering' import { MissingItemOrderError, @@ -13,8 +12,9 @@ import { UnknownMoveOriginError, UnknownMoveTargetError } from '../../errors/Error' +import { BulkImportResource } from '../interfaces/Resource' -export default class CachingAdapter implements Adapter { +export default class CachingAdapter implements Adapter, BulkImportResource { protected highestId: number protected bookmarksCache: Folder protected server: any @@ -29,7 +29,7 @@ export default class CachingAdapter implements Adapter { getLabel():string { const data = this.getData() - return data.username + '@' + url.parse(data.url).hostname + return data.username + '@' + new URL(data.url).hostname } async getBookmarksTree(): Promise { @@ -40,9 +40,13 @@ export default class CachingAdapter implements Adapter { if (bm.url === 'data:') { return false } - return Boolean(['https:', 'http:', 'ftp:', 'data:', 'javascript:', 'chrome:', 'file:'].includes( - url.parse(bm.url).protocol - )) + try { + return Boolean(['https:', 'http:', 'ftp:', 'data:', 'javascript:', 'chrome:', 'file:'].includes( + new URL(bm.url).protocol + )) + } catch (e) { + return false + } } async createBookmark(bm:Bookmark):Promise { @@ -234,4 +238,8 @@ export default class CachingAdapter implements Adapter { cancel() { // noop } + + isAvailable(): Promise { + return Promise.resolve(true) + } } diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index 789864959a..ede73abafc 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -8,7 +8,6 @@ import { Bookmark, Folder, ItemLocation, TItem } from '../Tree' import { Base64 } from 'js-base64' import AsyncLock from 'async-lock' import * as Parallel from 'async-parallel' -import url from 'url' import PQueue from 'p-queue' import flatten from 'lodash/flatten' import { BulkImportResource, LoadFolderChildrenResource, OrderFolderResource } from '../interfaces/Resource' @@ -58,7 +57,6 @@ interface IChildOrderItem { } const LOCK_INTERVAL = 2 * 60 * 1000 // Set lock every two minutes while syncing -const LOCK_TIMEOUT = 30 * 60 * 1000 // Override lock after half an hour export default class NextcloudBookmarksAdapter implements Adapter, BulkImportResource, LoadFolderChildrenResource, OrderFolderResource { private server: NextcloudBookmarksConfig @@ -107,27 +105,26 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes getLabel():string { const data = this.getData() - return data.username.includes('@') ? data.username + ' on ' + url.parse(data.url).hostname : data.username + '@' + url.parse(data.url).hostname + return data.username.includes('@') ? data.username + ' on ' + new URL(data.url).hostname : data.username + '@' + new URL(data.url).hostname } acceptsBookmark(bm: Bookmark):boolean { - return Boolean(~['https:', 'http:', 'ftp:'].indexOf(url.parse(bm.url).protocol)) + try { + return Boolean(~['https:', 'http:', 'ftp:'].indexOf(new URL(bm.url).protocol)) + } catch (e) { + return false + } } normalizeServerURL(input:string):string { - const serverURL = url.parse(input) + const serverURL = new URL(input) const indexLoc = serverURL.pathname.indexOf('index.php') - return url.format({ - protocol: serverURL.protocol, - auth: serverURL.auth, - host: serverURL.host, - port: serverURL.port, - pathname: - serverURL.pathname.substr(0, ~indexLoc ? indexLoc : undefined) + - (!~indexLoc && serverURL.pathname[serverURL.pathname.length - 1] !== '/' - ? '/' - : ''), - }) + if (!serverURL.pathname) serverURL.pathname = '' + serverURL.search = '' + serverURL.hash = '' + serverURL.pathname = serverURL.pathname.substring(0, ~indexLoc ? indexLoc : undefined) + const output = serverURL.toString() + return output + (output[output.length - 1] !== '/' ? '/' : '') } timeout(ms) { @@ -140,7 +137,13 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes async onSyncStart(needLock = true): Promise { if (Capacitor.getPlatform() === 'web') { const browser = (await import('../browser-api')).default - if (!(await browser.permissions.contains({ origins: [this.server.url + '/'] }))) { + let hasPermissions + try { + hasPermissions = await browser.permissions.contains({ origins: [this.server.url + '/'] }) + } catch (e) { + console.warn(e) + } + if (!hasPermissions) { throw new MissingPermissionsError() } } @@ -1028,4 +1031,8 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return json } + + isAvailable(): Promise { + return Promise.resolve(true) + } } diff --git a/src/lib/adapters/WebDav.ts b/src/lib/adapters/WebDav.ts index 02194b2cff..6e6c80aa7e 100644 --- a/src/lib/adapters/WebDav.ts +++ b/src/lib/adapters/WebDav.ts @@ -3,7 +3,6 @@ import XbelSerializer from '../serializers/Xbel' import Logger from '../Logger' import { Base64 } from 'js-base64' -import url from 'url' import Crypto from '../Crypto' import { AuthenticationError, @@ -51,17 +50,12 @@ export default class WebDavAdapter extends CachingAdapter { } normalizeServerURL(input) { - const serverURL = url.parse(input) + const serverURL = new URL(input) if (!serverURL.pathname) serverURL.pathname = '' - return url.format({ - protocol: serverURL.protocol, - auth: serverURL.auth, - host: serverURL.host, - port: serverURL.port, - pathname: - serverURL.pathname + - (serverURL.pathname[serverURL.pathname.length - 1] !== '/' ? '/' : '') - }) + serverURL.search = '' + serverURL.hash = '' + const output = serverURL.toString() + return output + (output[output.length - 1] !== '/' ? '/' : '') } cancel() { diff --git a/src/lib/browser/BrowserAccount.ts b/src/lib/browser/BrowserAccount.ts index dc8cff3ea9..c2f68f9c00 100644 --- a/src/lib/browser/BrowserAccount.ts +++ b/src/lib/browser/BrowserAccount.ts @@ -13,6 +13,7 @@ import { UnknownFolderItemOrderError } from '../../errors/Error' import {i18n} from '../native/I18n' +import { IResource, OrderFolderResource } from '../interfaces/Resource' export default class BrowserAccount extends Account { static async get(id:string):Promise { @@ -65,6 +66,16 @@ export default class BrowserAccount extends Account { } } + async getResource():Promise { + if (this.getData().localRoot !== 'tabs') { + return this.localTree + } else { + const LocalTabs = (await import('../LocalTabs')).default + this.localTabs = new LocalTabs(this.storage) + return this.localTabs + } + } + async updateFromStorage():Promise { const data = await this.storage.getAccountData(null) this.server.setData(data) @@ -100,8 +111,8 @@ export default class BrowserAccount extends Account { return i18n.getMessage('Error' + String(er.code).padStart(3, '0')) } if (er.list) { - if (er.list[0].code === 27) { - // Do not spam log with E027 (interrupted sync) + if (er.list[0].code === 26) { + // Do not spam log with E026 (cancelled sync) return this.stringifyError(er.list[0]) } return (await Promise.all(er.list diff --git a/src/lib/browser/BrowserAccountStorage.js b/src/lib/browser/BrowserAccountStorage.js index 64e28abc9c..d67dfa9a9b 100644 --- a/src/lib/browser/BrowserAccountStorage.js +++ b/src/lib/browser/BrowserAccountStorage.js @@ -153,4 +153,12 @@ export default class BrowserAccountStorage { async deleteMappings() { await BrowserAccountStorage.deleteEntry(`bookmarks[${this.accountId}].mappings`) } + + async getCurrentContinuation() { + return BrowserAccountStorage.getEntry(`bookmarks[${this.accountId}].continuation`) + } + + async setCurrentContinuation(continuation) { + await BrowserAccountStorage.changeEntry(`bookmarks[${this.accountId}].continuation`, (_) => ({...continuation, createdAt: Date.now()}), null) + } } diff --git a/src/lib/browser/BrowserController.js b/src/lib/browser/BrowserController.js index 41ca60200a..0477b6eec7 100644 --- a/src/lib/browser/BrowserController.js +++ b/src/lib/browser/BrowserController.js @@ -124,10 +124,8 @@ export default class BrowserController { async _receiveEvent(data, sendResponse) { const {type, params} = data - console.log('Message received', data) const result = await this[type](...params) sendResponse({type: type + 'Response', params: [result]}) - console.log('Sending message', {type: type + 'Response', params: [result]}) // checkSync after waiting a bit setTimeout(() => this.alarms.checkSync(), 3000) @@ -185,8 +183,6 @@ export default class BrowserController { // Debounce this function this.setEnabled(false) - console.log('Changes in browser Bookmarks detected...') - const allAccounts = await BrowserAccount.getAllAccounts() // Check which accounts contain the bookmark and which used to contain (track) it @@ -200,14 +196,11 @@ export default class BrowserController { // Filter out any accounts that are not tracking the bookmark .filter((account, i) => trackingAccountsFilter[i]) - console.log('onchange', {accountsToSync}) - // Now we check the account of the new folder let containingAccounts = [] try { const ancestors = await BrowserTree.getIdPathFromLocalId(localId) - console.log('onchange:', {ancestors, allAccounts}) containingAccounts = await BrowserAccount.getAccountsContainingLocalId( localId, ancestors, @@ -218,8 +211,6 @@ export default class BrowserController { console.log('Could not detect containing account from localId ', localId) } - console.log('onchange', accountsToSync.concat(containingAccounts)) - accountsToSync = uniqBy( accountsToSync.concat(containingAccounts), acc => acc.id @@ -290,14 +281,11 @@ export default class BrowserController { } async syncAccount(accountId, strategy) { - console.log('Called syncAccount ', accountId) if (!this.enabled) { - console.log('Flocccus controller is not enabled. Not syncing.') return } let account = await Account.get(accountId) if (account.getData().syncing) { - console.log('Account is already syncing. Not triggering another sync.') return } // executes long-running async work without letting the service worker to die @@ -381,6 +369,7 @@ export default class BrowserController { await acc.setData({ ...acc.getData(), syncing: false, + scheduled: true, }) } }) diff --git a/src/lib/browser/BrowserDetection.ts b/src/lib/browser/BrowserDetection.ts new file mode 100644 index 0000000000..76971c6e2f --- /dev/null +++ b/src/lib/browser/BrowserDetection.ts @@ -0,0 +1,5 @@ +export const isVivaldi = async() => { + const {default: browser} = await import('../browser-api.js') + const tabs = await browser.tabs.query({ active: true, currentWindow: true }) + return Boolean(tabs?.[0]?.['vivExtData']) +} diff --git a/src/lib/browser/BrowserTree.ts b/src/lib/browser/BrowserTree.ts index bc3f65fd89..b5a735732f 100644 --- a/src/lib/browser/BrowserTree.ts +++ b/src/lib/browser/BrowserTree.ts @@ -6,9 +6,9 @@ import PQueue from 'p-queue' import Account from '../Account' import { Bookmark, Folder, ItemLocation, ItemType } from '../Tree' import Ordering from '../interfaces/Ordering' -import url from 'url' import random from 'random' import seedrandom from 'seedrandom' +import { isVivaldi } from './BrowserDetection' let absoluteRoot: {id: string} @@ -29,6 +29,7 @@ export default class BrowserTree implements IResource { } async getBookmarksTree():Promise { + const isVivaldiBrowser = await isVivaldi() const [tree] = await browser.bookmarks.getSubTree(this.rootId) await this.absoluteRootPromise const allAccounts = await (await Account.getAccountClass()).getAllAccounts() @@ -43,7 +44,7 @@ export default class BrowserTree implements IResource { return } let overrideTitle, isRoot - if (node.parentId === this.absoluteRoot.id) { + if (node.parentId === this.absoluteRoot.id && !isVivaldiBrowser) { switch (node.id) { case '1': // Chrome case 'toolbar_____': // Firefox @@ -121,7 +122,7 @@ export default class BrowserTree implements IResource { return } try { - if (self.location.protocol === 'moz-extension:' && url.parse(bookmark.url).hostname === 'separator.floccus.org') { + if (self.location.protocol === 'moz-extension:' && new URL(bookmark.url).hostname === 'separator.floccus.org') { const node = await this.queue.add(async() => { Logger.log('(local)CREATE: executing create ', bookmark) return browser.bookmarks.create({ @@ -152,7 +153,7 @@ export default class BrowserTree implements IResource { return } try { - if (self.location.protocol === 'moz-extension:' && url.parse(bookmark.url).hostname === 'separator.floccus.org') { + if (self.location.protocol === 'moz-extension:' && new URL(bookmark.url).hostname === 'separator.floccus.org') { // noop } else { await this.queue.add(async() => { @@ -349,4 +350,8 @@ export default class BrowserTree implements IResource { } return absoluteRoot } + + isAvailable(): Promise { + return Promise.resolve(true) + } } diff --git a/src/lib/interfaces/AccountStorage.ts b/src/lib/interfaces/AccountStorage.ts index 02e4c57e48..2e033f9767 100644 --- a/src/lib/interfaces/AccountStorage.ts +++ b/src/lib/interfaces/AccountStorage.ts @@ -1,5 +1,6 @@ import Mappings from '../Mappings' import { Folder } from '../Tree' +import { ISerializedSyncProcess } from '../strategies/Default' export type TAccountStrategy = 'default' | 'overwrite' | 'slave' @@ -27,4 +28,6 @@ export default interface IAccountStorage { getMappings(): Promise; setMappings(data): Promise; deleteMappings(): Promise; + getCurrentContinuation(): Promise; + setCurrentContinuation(continuation: ISerializedSyncProcess|null): Promise; } diff --git a/src/lib/interfaces/Resource.ts b/src/lib/interfaces/Resource.ts index 08fa2b8a67..47b2d56da8 100644 --- a/src/lib/interfaces/Resource.ts +++ b/src/lib/interfaces/Resource.ts @@ -10,6 +10,7 @@ export interface IResource { createFolder(folder:Folder):Promise updateFolder(folder:Folder):Promise removeFolder(folder:Folder):Promise + isAvailable():Promise } export interface BulkImportResource extends IResource { diff --git a/src/lib/isTest.ts b/src/lib/isTest.ts new file mode 100644 index 0000000000..ea55d8fa0d --- /dev/null +++ b/src/lib/isTest.ts @@ -0,0 +1 @@ +export const isTest = (new URL(window.location.href)).pathname.includes('test') diff --git a/src/lib/native/NativeAccountStorage.js b/src/lib/native/NativeAccountStorage.js index cdb5f60d0c..3f3d0eaae9 100644 --- a/src/lib/native/NativeAccountStorage.js +++ b/src/lib/native/NativeAccountStorage.js @@ -153,4 +153,12 @@ export default class NativeAccountStorage { async deleteMappings() { await NativeAccountStorage.deleteEntry(`bookmarks[${this.accountId}].mappings`) } + + async getCurrentContinuation() { + return NativeAccountStorage.getEntry(`bookmarks[${this.accountId}].continuation`) + } + + async setCurrentContinuation(continuation) { + await NativeAccountStorage.changeEntry(`bookmarks[${this.accountId}].continuation`, (_) => continuation, null) + } } diff --git a/src/lib/native/NativeTree.ts b/src/lib/native/NativeTree.ts index 4d42e496be..d08907a8d5 100644 --- a/src/lib/native/NativeTree.ts +++ b/src/lib/native/NativeTree.ts @@ -94,4 +94,8 @@ export default class NativeTree extends CachingAdapter implements BulkImportReso })) return this.bookmarksCache.findFolder(id) } + + isAvailable(): Promise { + return Promise.resolve(true) + } } diff --git a/src/lib/strategies/Default.ts b/src/lib/strategies/Default.ts index a9267a9c68..1afe7ab74c 100644 --- a/src/lib/strategies/Default.ts +++ b/src/lib/strategies/Default.ts @@ -7,7 +7,7 @@ import { throttle } from 'throttle-debounce' import Mappings, { MappingSnapshot } from '../Mappings' import TResource, { OrderFolderResource, TLocalTree } from '../interfaces/Resource' import { TAdapter } from '../interfaces/Adapter' -import { FailsafeError, InterruptedSyncError } from '../../errors/Error' +import { CancelledSyncError, FailsafeError } from '../../errors/Error' import NextcloudBookmarksAdapter from '../adapters/NextcloudBookmarks' @@ -15,15 +15,25 @@ export default class SyncProcess { protected mappings: Mappings protected localTree: TLocalTree protected server: TAdapter - protected cacheTreeRoot: Folder + protected cacheTreeRoot: Folder|null protected canceled: boolean protected preserveOrder: boolean - protected progressCb: (progress:number)=>void + protected progressCb: (progress:number, actionsDone?:number)=>void protected localTreeRoot: Folder protected serverTreeRoot: Folder - protected actionsDone: number - protected actionsPlanned: number + protected actionsDone = 0 + protected actionsPlanned = 0 protected isFirefox: boolean + protected localPlan: Diff + protected serverPlan: Diff + protected doneLocalPlan: Diff + protected doneServerPlan: Diff + protected localReorderPlan: Diff + protected serverReorderPlan: Diff + protected flagLocalPostMoveMapping = false + protected flagLocalPostReorderReconciliation = false + protected flagServerPostMoveMapping = false + protected flagPostReorderReconciliation = false // The location that has precedence in case of conflicts protected masterLocation: TItemLocation @@ -31,37 +41,95 @@ export default class SyncProcess { constructor( mappings:Mappings, localTree:TLocalTree, - cacheTreeRoot:Folder, server:TAdapter, - progressCb:(progress:number)=>void + progressCb:(progress:number, actionsDone?:number)=>void ) { this.mappings = mappings this.localTree = localTree this.server = server - this.cacheTreeRoot = cacheTreeRoot this.preserveOrder = 'orderFolder' in this.server - this.progressCb = throttle(250, true, progressCb) as (progress:number)=>void + this.progressCb = throttle(250, true, progressCb) as (progress:number, actionsDone?:number)=>void this.actionsDone = 0 this.actionsPlanned = 0 this.canceled = false this.isFirefox = self.location.protocol === 'moz-extension:' } + setCacheTree(cacheTree: Folder) { + this.cacheTreeRoot = cacheTree + } + + setState({localTreeRoot, cacheTreeRoot, serverTreeRoot, localPlan, doneLocalPlan, serverPlan, doneServerPlan, serverReorderPlan, localReorderPlan, flagLocalPostMoveMapping, flagServerPostMoveMapping, flagPostReorderReconciliation}: any) { + if (typeof localTreeRoot !== 'undefined') { + this.localTreeRoot = Folder.hydrate(localTreeRoot) + } + if (typeof cacheTreeRoot !== 'undefined') { + this.cacheTreeRoot = Folder.hydrate(cacheTreeRoot) + } + if (typeof serverTreeRoot !== 'undefined') { + this.serverTreeRoot = Folder.hydrate(serverTreeRoot) + } + if (typeof localPlan !== 'undefined') { + this.localPlan = Diff.fromJSON(localPlan) + } + if (typeof serverPlan !== 'undefined') { + this.serverPlan = Diff.fromJSON(serverPlan) + } + if (typeof doneLocalPlan !== 'undefined') { + this.doneLocalPlan = Diff.fromJSON(doneLocalPlan) + } + if (typeof doneServerPlan !== 'undefined') { + this.doneServerPlan = Diff.fromJSON(doneServerPlan) + } + if (typeof localReorderPlan !== 'undefined') { + this.localReorderPlan = Diff.fromJSON(localReorderPlan) + } + if (typeof serverReorderPlan !== 'undefined') { + this.serverReorderPlan = Diff.fromJSON(serverReorderPlan) + } + this.flagLocalPostMoveMapping = flagLocalPostMoveMapping + this.flagServerPostMoveMapping = flagServerPostMoveMapping + this.flagPostReorderReconciliation = flagPostReorderReconciliation + } + async cancel() :Promise { this.canceled = true this.server.cancel() } updateProgress():void { + if (this.serverPlan && this.localPlan) { + this.actionsPlanned = this.serverPlan.getActions().length + this.localPlan.getActions().length + } else if ('revertPlan' in this) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.actionsPlanned = this.revertPlan.getActions().length + } Logger.log(`Executed ${this.actionsDone} actions from ${this.actionsPlanned} actions`) + if (typeof this.actionsDone === 'undefined') { + this.actionsDone = 0 + } this.actionsDone++ this.progressCb( Math.min( 1, 0.5 + (this.actionsDone / (this.actionsPlanned + 1)) * 0.5 - ) + ), + this.actionsDone + ) + } + + setProgress({actionsDone, actionsPlanned}: {actionsDone: number, actionsPlanned: number}) { + this.actionsDone = actionsDone + this.actionsPlanned = actionsPlanned + this.progressCb( + Math.min( + 1, + 0.5 + (this.actionsDone / (this.actionsPlanned + 1)) * 0.5 + ), + this.actionsDone ) } @@ -80,7 +148,7 @@ export default class SyncProcess { this.progressCb(0.35) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } Logger.log({localTreeRoot: this.localTreeRoot, serverTreeRoot: this.serverTreeRoot, cacheTreeRoot: this.cacheTreeRoot}) @@ -90,7 +158,7 @@ export default class SyncProcess { this.progressCb(0.5) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } const unmappedServerPlan = await this.reconcileDiffs(localDiff, serverDiff, ItemLocation.SERVER) @@ -98,47 +166,87 @@ export default class SyncProcess { // have to get snapshot after reconciliation, because of concurrent creation reconciliation let mappingsSnapshot = this.mappings.getSnapshot() Logger.log('Mapping server plan') - let serverPlan = unmappedServerPlan.map(mappingsSnapshot, ItemLocation.SERVER, (action) => action.type !== ActionType.REORDER && action.type !== ActionType.MOVE) + this.serverPlan = unmappedServerPlan.map(mappingsSnapshot, ItemLocation.SERVER, (action) => action.type !== ActionType.REORDER && action.type !== ActionType.MOVE) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } const unmappedLocalPlan = await this.reconcileDiffs(serverDiff, localDiff, ItemLocation.LOCAL) // have to get snapshot after reconciliation, because of concurrent creation reconciliation mappingsSnapshot = this.mappings.getSnapshot() Logger.log('Mapping local plan') - let localPlan = unmappedLocalPlan.map(mappingsSnapshot, ItemLocation.LOCAL, (action) => action.type !== ActionType.REORDER && action.type !== ActionType.MOVE) + this.localPlan = unmappedLocalPlan.map(mappingsSnapshot, ItemLocation.LOCAL, (action) => action.type !== ActionType.REORDER && action.type !== ActionType.MOVE) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } - Logger.log({localPlan, serverPlan}) + this.doneServerPlan = new Diff + this.doneLocalPlan = new Diff - this.actionsPlanned = serverPlan.getActions().length + localPlan.getActions().length + Logger.log({localPlan: this.localPlan, serverPlan: this.serverPlan}) - this.applyFailsafe(localPlan) + this.actionsPlanned = this.serverPlan.getActions().length + this.localPlan.getActions().length + + this.applyFailsafe(this.localPlan) Logger.log('Executing server plan') - serverPlan = await this.execute(this.server, serverPlan, ItemLocation.SERVER) + await this.execute(this.server, this.serverPlan, ItemLocation.SERVER, this.doneServerPlan) Logger.log('Executing local plan') - localPlan = await this.execute(this.localTree, localPlan, ItemLocation.LOCAL) + await this.execute(this.localTree, this.localPlan, ItemLocation.LOCAL, this.doneLocalPlan) // mappings have been updated, reload mappingsSnapshot = this.mappings.getSnapshot() - const localReorder = this.reconcileReorderings(localPlan, serverPlan, mappingsSnapshot) - .map(mappingsSnapshot, ItemLocation.LOCAL) + if ('orderFolder' in this.server) { + this.localReorderPlan = this.reconcileReorderings(this.localPlan, this.doneServerPlan, mappingsSnapshot) + .map(mappingsSnapshot, ItemLocation.LOCAL) + + this.serverReorderPlan = this.reconcileReorderings(this.serverPlan, this.doneLocalPlan, mappingsSnapshot) + .map(mappingsSnapshot, ItemLocation.SERVER) + + this.flagPostReorderReconciliation = true + + Logger.log('Executing reorderings') + await Promise.all([ + this.executeReorderings(this.server, this.serverReorderPlan), + this.executeReorderings(this.localTree, this.localReorderPlan), + ]) + } + } + + async resumeSync(): Promise { + if (typeof this.localPlan === 'undefined' || typeof this.serverPlan === 'undefined') { + Logger.log('Continuation loaded from storage is incomplete. Falling back to a complete new sync iteration') + return this.sync() + } + Logger.log('Resuming sync with the following plans:') + Logger.log({localPlan: this.localPlan, serverPlan: this.serverPlan}) + + Logger.log('Executing server plan') + await this.execute(this.server, this.serverPlan, ItemLocation.SERVER, this.doneServerPlan) + Logger.log('Executing local plan') + await this.execute(this.localTree, this.localPlan, ItemLocation.LOCAL, this.doneLocalPlan) - const serverReorder = this.reconcileReorderings(serverPlan, localPlan, mappingsSnapshot) - .map(mappingsSnapshot, ItemLocation.SERVER) + // mappings have been updated, reload + const mappingsSnapshot = this.mappings.getSnapshot() if ('orderFolder' in this.server) { + if (!this.flagPostReorderReconciliation) { + this.localReorderPlan = this.reconcileReorderings(this.localPlan, this.doneServerPlan, mappingsSnapshot) + .map(mappingsSnapshot, ItemLocation.LOCAL) + + this.serverReorderPlan = this.reconcileReorderings(this.serverPlan, this.doneLocalPlan, mappingsSnapshot) + .map(mappingsSnapshot, ItemLocation.SERVER) + } + + this.flagPostReorderReconciliation = true + Logger.log('Executing reorderings') await Promise.all([ - this.executeReorderings(this.server, serverReorder), - this.executeReorderings(this.localTree, localReorder), + this.executeReorderings(this.server, this.serverReorderPlan), + this.executeReorderings(this.localTree, this.localReorderPlan), ]) } } @@ -522,31 +630,47 @@ export default class SyncProcess { return targetPlan } - async execute(resource:TResource, plan:Diff, targetLocation:TItemLocation):Promise { + async execute(resource:TResource, plan:Diff, targetLocation:TItemLocation, donePlan: Diff = null, isSubPlan = false):Promise { Logger.log('Executing plan for ' + targetLocation) - const run = (action) => this.executeAction(resource, action, targetLocation) + const run = (action) => this.executeAction(resource, action, targetLocation, plan, donePlan) + let mappedPlan - Logger.log(targetLocation + ': executing CREATEs and UPDATEs') - await Parallel.each(plan.getActions().filter(action => action.type === ActionType.CREATE || action.type === ActionType.UPDATE), run) + if (isSubPlan || ((targetLocation === ItemLocation.LOCAL && !this.flagLocalPostMoveMapping) || (targetLocation === ItemLocation.SERVER && !this.flagServerPostMoveMapping))) { + Logger.log(targetLocation + ': executing CREATEs and UPDATEs') + await Parallel.each(plan.getActions().filter(action => action.type === ActionType.CREATE || action.type === ActionType.UPDATE), run) - if (this.canceled) { - throw new InterruptedSyncError() - } + if (this.canceled) { + throw new CancelledSyncError() + } - const mappingsSnapshot = this.mappings.getSnapshot() - Logger.log(targetLocation + ': mapping MOVEs') - const mappedPlan = plan.map(mappingsSnapshot, targetLocation, (action) => action.type === ActionType.MOVE) - const batches = Diff.sortMoves(mappedPlan.getActions(ActionType.MOVE), targetLocation === ItemLocation.SERVER ? this.serverTreeRoot : this.localTreeRoot) + const mappingsSnapshot = this.mappings.getSnapshot() + Logger.log(targetLocation + ': mapping MOVEs') + mappedPlan = plan.map(mappingsSnapshot, targetLocation, (action) => action.type === ActionType.MOVE) + + if (!isSubPlan) { + if (targetLocation === ItemLocation.LOCAL) { + this.localPlan = mappedPlan + this.flagLocalPostMoveMapping = true + } else { + this.serverPlan = mappedPlan + this.flagServerPostMoveMapping = true + } + } + } else { + mappedPlan = plan + } if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } + const batches = Diff.sortMoves(mappedPlan.getActions(ActionType.MOVE), targetLocation === ItemLocation.SERVER ? this.serverTreeRoot : this.localTreeRoot) + Logger.log(targetLocation + ': executing MOVEs') - await Parallel.each(batches, batch => Promise.all(batch.map(run)), 1) + await Parallel.each(batches, batch => Parallel.each(batch, run), 1) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } Logger.log(targetLocation + ': executing REMOVEs') @@ -555,18 +679,36 @@ export default class SyncProcess { return mappedPlan } - async executeAction(resource:TResource, action:Action, targetLocation:TItemLocation):Promise { + async executeAction(resource:TResource, action:Action, targetLocation:TItemLocation, plan: Diff, donePlan: Diff = null):Promise { Logger.log('Executing action ', action) const item = action.payload + const done = () => { + plan.retract(action) + // TODO: This is kind of a hack :/ + if (targetLocation === ItemLocation.LOCAL) { + this.localPlan && this.localPlan.retract(action) + } else { + this.localPlan && this.serverPlan.retract(action) + } + if ('revertPlan' in this) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.revertPlan.retract(action) + } + if (donePlan) { + donePlan.commit(action) + } + this.updateProgress() + } if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } if (action.type === ActionType.REMOVE) { await action.payload.visitRemove(resource) await this.removeMapping(resource, item) - this.updateProgress() + done() return } @@ -574,7 +716,7 @@ export default class SyncProcess { const id = await action.payload.visitCreate(resource) if (typeof id === 'undefined') { // undefined means we couldn't create the item. we're ignoring it - this.updateProgress() + done() return } item.id = id @@ -607,7 +749,7 @@ export default class SyncProcess { await this.addMapping(resource, oldItem, newId) }) - this.updateProgress() + done() return } catch (e) { Logger.log('Bulk import failed, continuing with normal creation', e) @@ -617,30 +759,29 @@ export default class SyncProcess { // Create a sub plan if (action.oldItem && action.oldItem instanceof Folder) { const subPlan = new Diff - action.oldItem.children.forEach((child) => subPlan.commit({ type: ActionType.CREATE, payload: child })) - let mappingsSnapshot = this.mappings.getSnapshot() + action.oldItem.children.forEach((child) => { + const newAction : Action = { type: ActionType.CREATE, payload: child } + subPlan.commit(newAction) + plan.commit(newAction) + }) + const mappingsSnapshot = this.mappings.getSnapshot() const mappedSubPlan = subPlan.map(mappingsSnapshot, targetLocation) - await this.execute(resource, mappedSubPlan, targetLocation) + Logger.log('executing sub plan') + await this.execute(resource, mappedSubPlan, targetLocation, null, true) - if (item.children.length > 1) { + if ('orderFolder' in resource && item.children.length > 1) { // Order created items after the fact, as they've been created concurrently - const subOrder = new Diff() - subOrder.commit({ + plan.commit({ type: ActionType.REORDER, oldItem: action.payload, payload: action.oldItem, order: action.oldItem.children.map(i => ({ type: i.type, id: i.id })) }) - mappingsSnapshot = this.mappings.getSnapshot() - const mappedOrder = subOrder.map(mappingsSnapshot, targetLocation) - if ('orderFolder' in resource) { - await this.executeReorderings(resource, mappedOrder) - } } } } - this.updateProgress() + done() return } @@ -648,7 +789,7 @@ export default class SyncProcess { if (action.type === ActionType.UPDATE || action.type === ActionType.MOVE) { await action.payload.visitUpdate(resource) await this.addMapping(resource, action.oldItem, item.id) - this.updateProgress() + done() } } @@ -739,7 +880,7 @@ export default class SyncProcess { const item = action.payload if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } if (action.order.length <= 1) { @@ -764,6 +905,7 @@ export default class SyncProcess { Logger.log('Failed to execute REORDER: ' + e.message + '\nMoving on.') Logger.log(e) } + reorderings.retract(action) this.updateProgress() }) } @@ -800,7 +942,7 @@ export default class SyncProcess { async loadChildren(serverItem:TItem, mappingsSnapshot:MappingSnapshot):Promise { if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } if (!(serverItem instanceof Folder)) return if (!('loadFolderChildren' in this.server)) return @@ -873,4 +1015,58 @@ export default class SyncProcess { } }) } + + toJSON(): ISerializedSyncProcess { + return { + strategy: 'default', + localTreeRoot: this.localTreeRoot && this.localTreeRoot.clone(false), + cacheTreeRoot: this.cacheTreeRoot && this.cacheTreeRoot.clone(false), + serverTreeRoot: this.serverTreeRoot && this.serverTreeRoot.clone(false), + localPlan: this.localPlan && this.localPlan.toJSON(), + doneLocalPlan: this.doneLocalPlan && this.doneLocalPlan.toJSON(), + serverPlan: this.serverPlan && this.serverPlan.toJSON(), + doneServerPlan: this.doneServerPlan && this.doneServerPlan.toJSON(), + serverReorderPlan: this.serverReorderPlan && this.serverReorderPlan.toJSON(), + localReorderPlan: this.localReorderPlan && this.localReorderPlan.toJSON(), + actionsDone: this.actionsDone, + actionsPlanned: this.actionsPlanned, + flagLocalPostMoveMapping: this.flagLocalPostMoveMapping, + flagLocalPostReorderReconciliation: this.flagLocalPostReorderReconciliation, + flagServerPostMoveMapping: this.flagServerPostMoveMapping, + flagPostReorderReconciliation: this.flagPostReorderReconciliation + } + } + + static async fromJSON(mappings:Mappings, + localTree:TLocalTree, + server:TAdapter, + progressCb:(progress:number)=>void, + json: any) { + let strategy: SyncProcess + let MergeSyncProcess: typeof SyncProcess + let UnidirectionalSyncProcess: typeof SyncProcess + switch (json.strategy) { + case 'default': + strategy = new SyncProcess(mappings, localTree, server, progressCb) + break + case 'merge': + MergeSyncProcess = (await import('./Merge')).default + strategy = new MergeSyncProcess(mappings, localTree, server, progressCb) + break + case 'unidirectional': + UnidirectionalSyncProcess = (await import('./Unidirectional')).default + strategy = new UnidirectionalSyncProcess(mappings, localTree, server, progressCb) + break + default: + throw new Error('Unknown strategy: ' + json.strategy) + } + strategy.setProgress(json) + strategy.setState(json) + return strategy + } +} + +export interface ISerializedSyncProcess { + strategy: 'default' | 'merge' | 'unidirectional' + [k: string]: any } diff --git a/src/lib/strategies/Merge.ts b/src/lib/strategies/Merge.ts index 7b450360c6..5a5a9571df 100644 --- a/src/lib/strategies/Merge.ts +++ b/src/lib/strategies/Merge.ts @@ -2,11 +2,11 @@ import { ItemLocation, TItemLocation } from '../Tree' import Diff, { Action, ActionType, CreateAction, MoveAction } from '../Diff' import Scanner from '../Scanner' import * as Parallel from 'async-parallel' -import Default from './Default' +import DefaultSyncProcess, { ISerializedSyncProcess } from './Default' import Mappings, { MappingSnapshot } from '../Mappings' import Logger from '../Logger' -export default class MergeSyncProcess extends Default { +export default class MergeSyncProcess extends DefaultSyncProcess { async getDiffs():Promise<{localDiff:Diff, serverDiff:Diff}> { // If there's no cache, diff the two trees directly const newMappings = [] @@ -168,4 +168,10 @@ export default class MergeSyncProcess extends Default { Logger.log('Merge strategy: Load complete tree from server') this.serverTreeRoot = await this.server.getBookmarksTree(true) } + toJSON(): ISerializedSyncProcess { + return { + ...DefaultSyncProcess.prototype.toJSON.apply(this), + strategy: 'merge' + } + } } diff --git a/src/lib/strategies/Unidirectional.ts b/src/lib/strategies/Unidirectional.ts index a8ba2cb5b2..fbb39e8449 100644 --- a/src/lib/strategies/Unidirectional.ts +++ b/src/lib/strategies/Unidirectional.ts @@ -1,15 +1,19 @@ -import DefaultStrategy from './Default' +import DefaultStrategy, { ISerializedSyncProcess } from './Default' import Diff, { Action, ActionType } from '../Diff' import * as Parallel from 'async-parallel' import Mappings, { MappingSnapshot } from '../Mappings' import { Folder, ItemLocation, TItem, TItemLocation } from '../Tree' import Logger from '../Logger' -import { InterruptedSyncError } from '../../errors/Error' +import { CancelledSyncError } from '../../errors/Error' import MergeSyncProcess from './Merge' -import TResource from '../interfaces/Resource' +import TResource, { IResource, OrderFolderResource } from '../interfaces/Resource' export default class UnidirectionalSyncProcess extends DefaultStrategy { protected direction: TItemLocation + protected revertPlan: Diff + protected revertOrderings: Diff + protected flagPreReordering = false + protected sourceDiff: Diff setDirection(direction: TItemLocation): void { this.direction = direction @@ -32,7 +36,7 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { this.progressCb(0.35) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } const {localDiff, serverDiff} = await this.getDiffs() @@ -40,7 +44,7 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { this.progressCb(0.5) if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } let sourceDiff: Diff, targetDiff: Diff, target: TResource @@ -58,19 +62,20 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { // First revert slave modifications - const revertPlan = await this.revertDiff(targetDiff, this.direction) - this.actionsPlanned = revertPlan.getActions().length - Logger.log({revertPlan}) + this.sourceDiff = sourceDiff + this.revertPlan = await this.revertDiff(targetDiff, this.direction) + this.actionsPlanned = this.revertPlan.getActions().length + Logger.log({revertPlan: this.revertPlan}) if (this.direction === ItemLocation.LOCAL) { - this.applyFailsafe(revertPlan) + this.applyFailsafe(this.revertPlan) } if (this.canceled) { - throw new InterruptedSyncError() + throw new CancelledSyncError() } Logger.log('Executing ' + this.direction + ' revert plan') - await this.execute(target, revertPlan, this.direction) + await this.execute(target, this.revertPlan, this.direction) const mappingsSnapshot = this.mappings.getSnapshot() Logger.log('Mapping reorderings') @@ -83,9 +88,45 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { Logger.log({revertOrderings: revertOrderings.getActions(ActionType.REORDER)}) if ('orderFolder' in target) { - await Promise.all([ - this.executeReorderings(target, revertOrderings), - ]) + await this.executeReorderings(target, revertOrderings) + } + } + + async resumeSync(): Promise { + if (typeof this.revertPlan === 'undefined') { + Logger.log('Continuation loaded from storage is incomplete. Falling back to a complete new sync iteration') + return this.sync() + } + Logger.log('Resuming sync with the following plan:') + Logger.log({revertPlan: this.revertPlan}) + + let target: IResource|OrderFolderResource + if (this.direction === ItemLocation.SERVER) { + target = this.server + } else { + target = this.localTree + } + + Logger.log('Executing ' + this.direction + ' revert plan') + await this.execute(target, this.revertPlan, this.direction) + + if ('orderFolder' in target) { + if (!this.flagPostReorderReconciliation) { + // mappings have been updated, reload + const mappingsSnapshot = this.mappings.getSnapshot() + Logger.log('Mapping reorderings') + this.revertOrderings = this.sourceDiff.map( + mappingsSnapshot, + this.direction, + (action: Action) => action.type === ActionType.REORDER, + true + ) + } + + this.flagPostReorderReconciliation = true + + Logger.log('Executing reorderings') + await this.executeReorderings(target, this.revertOrderings) } } @@ -162,4 +203,37 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { } return newItem } + + setState({localTreeRoot, cacheTreeRoot, serverTreeRoot, direction, revertPlan, revertOrderings, flagPreReordering, sourceDiff}: any) { + this.setDirection(direction) + this.localTreeRoot = Folder.hydrate(localTreeRoot) + this.cacheTreeRoot = Folder.hydrate(cacheTreeRoot) + this.serverTreeRoot = Folder.hydrate(serverTreeRoot) + if (typeof revertPlan !== 'undefined') { + this.revertPlan = Diff.fromJSON(revertPlan) + } + if (typeof sourceDiff !== 'undefined') { + this.sourceDiff = Diff.fromJSON(sourceDiff) + } + if (typeof revertOrderings !== 'undefined') { + this.revertOrderings = Diff.fromJSON(revertOrderings) + } + this.flagPreReordering = flagPreReordering + } + + toJSON(): ISerializedSyncProcess { + return { + strategy: 'unidirectional', + direction: this.direction, + localTreeRoot: this.localTreeRoot && this.localTreeRoot.clone(false), + cacheTreeRoot: this.localTreeRoot && this.cacheTreeRoot.clone(false), + serverTreeRoot: this.localTreeRoot && this.serverTreeRoot.clone(false), + sourceDiff: this.sourceDiff, + revertPlan: this.revertPlan, + revertOrderings: this.revertOrderings, + flagPreReordering: this.flagPreReordering, + actionsDone: this.actionsDone, + actionsPlanned: this.actionsPlanned, + } + } } diff --git a/src/test/test.js b/src/test/test.js index 86d99c3398..5b8ae56e17 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -144,13 +144,13 @@ describe('Floccus', function() { }, { type: 'google-drive', - bookmark_file: random.float() + '.xbel', + bookmark_file: Math.random() + '.xbel', password: '', refreshToken: CREDENTIALS.password, }, { type: 'google-drive', - bookmark_file: random.float() + '.xbel', + bookmark_file: Math.random() + '.xbel', password: random.float(), refreshToken: CREDENTIALS.password, }, @@ -2250,7 +2250,7 @@ describe('Floccus', function() { bookmark.parentId = serverTree.children.find(folder => folder.title !== 'foo').id const fooFolder = serverTree.children.find(folder => folder.title === 'foo') await adapter.updateBookmark(new Bookmark(bookmark)) - // toLowerCase to accomodate chrome (since we normalize the title) + // toLowerCase to accommodate chrome (since we normalize the title) const secondBookmark = serverTree.children.find(folder => folder.title.toLowerCase() === secondBookmarkFolderTitle.toLowerCase()).children.find(item => item.type === 'bookmark') secondBookmark.parentId = fooFolder.id await adapter.updateBookmark(secondBookmark) @@ -2725,7 +2725,7 @@ describe('Floccus', function() { bookmark.parentId = serverTree.children.find(folder => folder.title !== 'foo').id const fooFolder = serverTree.children.find(folder => folder.title === 'foo') await adapter.updateBookmark(new Bookmark(bookmark)) - // toLowerCase to accomodate chrome (since we normalize the title) + // toLowerCase to accommodate chrome (since we normalize the title) const secondBookmark = serverTree.children.find(folder => folder.title.toLowerCase() === secondBookmarkFolderTitle.toLowerCase()).children.find(item => item.type === 'bookmark') secondBookmark.parentId = fooFolder.id await adapter.updateBookmark(secondBookmark) @@ -4814,7 +4814,7 @@ describe('Floccus', function() { const setInterrupt = () => { if (!timeouts.length) { timeouts = new Array(1000).fill(0).map(() => - ACCOUNT_DATA.type === 'nextcloud-bookmarks' ? random.int(50000, 150000) : random.int(1000,30000) + ACCOUNT_DATA.type === 'nextcloud-bookmarks' ? random.int(50000, 150000) : random.int(100,3000) ) } const timeout = timeouts[(i++) % 1000] @@ -4841,10 +4841,29 @@ describe('Floccus', function() { await account2.init() if (ACCOUNT_DATA.type === 'fake') { - // Wrire both accounts to the same fake db - account2.server.bookmarksCache = account1.server.bookmarksCache = new Folder( + // Wire both accounts to the same fake db + // We do not set the cache properties to the same object, because we want to only write onSynComplete + let fakeServerDb = new Folder( { id: '', title: 'root', location: 'Server' } ) + account1.server.bookmarksCache = new Folder( + { id: '', title: 'root', location: 'Server' } + ) + account2.server.bookmarksCache = new Folder( + { id: '', title: 'root', location: 'Server' } + ) + account1.server.onSyncStart = () => { + account1.server.bookmarksCache = fakeServerDb.clone(false) + } + account1.server.onSyncComplete = () => { + fakeServerDb = account1.server.bookmarksCache.clone(false) + } + account2.server.onSyncStart = () => { + account2.server.bookmarksCache = fakeServerDb.clone(false) + } + account2.server.onSyncComplete = () => { + fakeServerDb = account2.server.bookmarksCache.clone(false) + } account2.server.__defineSetter__('highestId', (id) => { account1.server.highestId = id }) @@ -5056,7 +5075,7 @@ describe('Floccus', function() { tree1AfterFinalSync = null }) - it('should handle fuzzed changes', async function() { + it('should handle fuzzed changes from one client', async function() { const localRoot = account1.getData().localRoot let bookmarks = [] let folders = [] @@ -5632,7 +5651,7 @@ describe('Floccus', function() { await randomlyManipulateTreeWithDeletions(account1, folders1, bookmarks1, 35) await randomlyManipulateTreeWithDeletions(account2, folders2, bookmarks2, 35) - console.log(' acc1: Moved items') + console.log(' acc1&acc2: Moved items') let tree1BeforeSync = await account1.localTree.getBookmarksTree( true @@ -5749,11 +5768,8 @@ describe('Floccus', function() { serverTreeAfterInit = null } }) - - it.skip('should handle fuzzed changes with deletions from two clients with interrupts', async function() { - if (ACCOUNT_DATA.type === 'nextcloud-bookmarks' && ACCOUNT_DATA.oldAPIs) { - return this.skip() - } + let interruptBenchmark + it('should handle fuzzed changes with deletions from two clients with interrupts' + (ACCOUNT_DATA.type === 'fake' ? ' (with caching)' : ''), interruptBenchmark = async function() { const localRoot = account1.getData().localRoot let bookmarks1 = [] let folders1 = [] @@ -5872,7 +5888,7 @@ describe('Floccus', function() { await randomlyManipulateTreeWithDeletions(account1, folders1, bookmarks1, 35) await randomlyManipulateTreeWithDeletions(account2, folders2, bookmarks2, 35) - console.log(' acc1: Moved items') + console.log(' acc1 &acc2: Moved items') let tree1BeforeSync = await account1.localTree.getBookmarksTree( true @@ -5985,6 +6001,21 @@ describe('Floccus', function() { } }) + if (ACCOUNT_DATA.type === 'fake') { + it('should handle fuzzed changes with deletions from two clients with interrupts (no caching adapter)', async function() { + // Wire both accounts to the same fake db + // We set the cache properties to the same object, because we want to simulate nextcloud-bookmarks + account1.server.bookmarksCache = account2.server.bookmarksCache = new Folder( + { id: '', title: 'root', location: 'Server' } + ) + delete account1.server.onSyncStart + delete account1.server.onSyncComplete + delete account2.server.onSyncStart + delete account2.server.onSyncComplete + await interruptBenchmark() + }) + } + it('unidirectional should handle fuzzed changes from two clients', async function() { await account2.setData({...account2.getData(), strategy: 'slave'}) const localRoot = account1.getData().localRoot @@ -6658,7 +6689,7 @@ async function syncAccountWithInterrupts(account) { try { expect(account.getData().error).to.not.be.ok } catch (e) { - if (!account.getData().error.includes('E027')) { + if (!account.getData().error.includes('E026')) { throw e } else { console.log(account.getData().error) diff --git a/src/ui/components/OptionSyncFolder.vue b/src/ui/components/OptionSyncFolder.vue index 0f0a700144..adb7833746 100644 --- a/src/ui/components/OptionSyncFolder.vue +++ b/src/ui/components/OptionSyncFolder.vue @@ -89,6 +89,8 @@