From 3a4be195d5604d75efed48ea2c7a0d63a6fd7ec7 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 28 Aug 2024 14:50:57 +0200 Subject: [PATCH 01/13] feat: Implement linkwarden sync Signed-off-by: Marcel Klehr --- .github/workflows/tests.yml | 2 + src/lib/Account.ts | 1 + src/lib/adapters/Linkwarden.ts | 330 +++++++++++++++++++++++++++++++++ src/test/test.js | 50 +++-- test/selenium-runner.js | 13 +- 5 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 src/lib/adapters/Linkwarden.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d90a91b883..342f5e9ae6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,6 +100,7 @@ jobs: - git-html - google-drive - google-drive-encrypted + - linkwarden test-name: - test - benchmark root @@ -253,6 +254,7 @@ jobs: FLOCCUS_TEST_SEED: ${{ github.sha }} GIST_TOKEN: ${{ secrets.GIST_TOKEN }} GOOGLE_API_REFRESH_TOKEN: ${{ secrets.GOOGLE_API_REFRESH_TOKEN }} + LINKWARDEN_TOKEN: ${{ secrets.LINKWARDEN_TOKEN }} APP_VERSION: ${{ matrix.app-version }} run: | npm run test diff --git a/src/lib/Account.ts b/src/lib/Account.ts index a235aefbef..cb72687745 100644 --- a/src/lib/Account.ts +++ b/src/lib/Account.ts @@ -17,6 +17,7 @@ import * as Sentry from '@sentry/vue' declare const DEBUG: boolean // register Adapters +AdapterFactory.register('linkwarden', async() => (await import('./adapters/Linkwarden')).default) AdapterFactory.register('nextcloud-folders', async() => (await import('./adapters/NextcloudBookmarks')).default) AdapterFactory.register('nextcloud-bookmarks', async() => (await import('./adapters/NextcloudBookmarks')).default) AdapterFactory.register('webdav', async() => (await import('./adapters/WebDav')).default) diff --git a/src/lib/adapters/Linkwarden.ts b/src/lib/adapters/Linkwarden.ts new file mode 100644 index 0000000000..13b0d1af8b --- /dev/null +++ b/src/lib/adapters/Linkwarden.ts @@ -0,0 +1,330 @@ +import Adapter from '../interfaces/Adapter' +import { Bookmark, Folder, ItemLocation, TItemLocation } from '../Tree' +import PQueue from 'p-queue' +import { IResource } from '../interfaces/Resource' +import Logger from '../Logger' +import { + AuthenticationError, + CancelledSyncError, HttpError, + NetworkError, ParseResponseError, + RedirectError, + RequestTimeoutError +} from '../../errors/Error' +import { Capacitor, CapacitorHttp as Http } from '@capacitor/core' + +export interface LinkwardenConfig { + type: 'linkwarden' + url: string + username: string + password: string + serverFolder: string, + includeCredentials?: boolean + allowRedirects?: boolean + allowNetwork?: boolean + label?: string +} + +const TIMEOUT = 300000 + +export default class LinkwardenAdapter implements Adapter, IResource { + private server: LinkwardenConfig + private fetchQueue: PQueue + private abortController: AbortController + private abortSignal: AbortSignal + private canceled: boolean + + constructor(server: LinkwardenConfig) { + this.server = server + this.fetchQueue = new PQueue({ concurrency: 12 }) + this.abortController = new AbortController() + this.abortSignal = this.abortController.signal + } + + static getDefaultValues(): LinkwardenConfig { + return { + type: 'linkwarden', + url: 'https://example.org', + username: 'bob', + password: 's3cret', + serverFolder: 'Floccus', + includeCredentials: false, + allowRedirects: false, + allowNetwork: false, + } + } + + acceptsBookmark(bookmark: Bookmark): boolean { + return true + } + + cancel(): void { + this.canceled = true + this.abortController.abort() + } + + setData(data:LinkwardenConfig):void { + this.server = { ...data } + } + + getData(): LinkwardenConfig { + return { ...LinkwardenAdapter.getDefaultValues(), ...this.server } + } + + getLabel(): string { + const data = this.getData() + return data.label || (data.username.includes('@') ? data.username + ' on ' + new URL(data.url).hostname : data.username + '@' + new URL(data.url).hostname) + } + + onSyncComplete(): Promise { + return Promise.resolve(undefined) + } + + onSyncFail(): Promise { + return Promise.resolve(undefined) + } + + onSyncStart(needLock?: boolean, forceLock?: boolean): Promise { + this.canceled = false + return Promise.resolve(undefined) + } + + async createBookmark(bookmark: Bookmark): Promise { + Logger.log('(linkwarden)CREATE', {bookmark}) + const {response} = await this.sendRequest( + 'POST', '/api/v1/links', + 'application/json', + { + url: bookmark.url, + name: bookmark.title, + collection: { + id: bookmark.parentId, + }, + }) + return response.id + } + + async updateBookmark(bookmark: Bookmark): Promise { + Logger.log('(linkwarden)UPDATE', {bookmark}) + const {response: collection} = await this.sendRequest('GET', `/api/v1/collections/${bookmark.parentId}`) + await this.sendRequest( + 'PUT', `/api/v1/links/${bookmark.id}`, + 'application/json', + { + url: bookmark.url, + name: bookmark.title, + tags: [], + collection: { + id: bookmark.parentId, + name: collection.name, + ownerId: collection.ownerId, + }, + }) + } + + async removeBookmark(bookmark: Bookmark): Promise { + Logger.log('(linkwarden)DELETE', {bookmark}) + await this.sendRequest('DELETE', `/api/v1/links/${bookmark.id}`) + } + + async createFolder(folder: Folder): Promise { + Logger.log('(linkwarden)CREATEFOLDER', {folder}) + const {response} = await this.sendRequest( + 'POST', '/api/v1/collections', + 'application/json', + { + name: folder.title, + parentId: folder.parentId, + }) + return response.id + } + + async updateFolder(folder: Folder): Promise { + Logger.log('(linkwarden)UPDATEFOLDER', {folder}) + const {response: collection} = await this.sendRequest('GET', `/api/v1/collections/${folder.id}`) + await this.sendRequest( + 'PUT', `/api/v1/collections/${folder.id}`, + 'application/json', + { + ...collection, + name: folder.title, + parentId: folder.parentId, + }) + } + + async removeFolder(folder: Folder): Promise { + Logger.log('(linkwarden)DELETEFOLDER', {folder}) + await this.sendRequest('DELETE', `/api/v1/collections/${folder.id}`) + } + + async getBookmarksTree(loadAll?: boolean): Promise> { + const {response: links} = await this.sendRequest('GET', `/api/v1/links`) + const {response: collections} = await this.sendRequest('GET', `/api/v1/collections`) + + let rootCollection = collections.find(collection => collection.name === this.server.serverFolder && collection.parentId === null) + if (!rootCollection) { + ({response: rootCollection} = await this.sendRequest( + 'POST', '/api/v1/collections', + 'application/json', + { + name: this.server.serverFolder, + })) + } + + const buildTree = (collection) => { + return new Folder({ + id: collection.id, + title: collection.name, + parentId: collection.parentId, + location: ItemLocation.SERVER, + children: collections + .filter(col => col.parentId === collection.id) + .map(buildTree).concat( + links + .filter(link => link.collectionId === collection.id) + .map(link => new Bookmark({ + id: link.id, + title: link.name, + parentId: link.collectionId, + url: link.url, + location: ItemLocation.SERVER, + })) + ), + }) + } + + return buildTree(rootCollection) + } + + async isAvailable(): Promise { + return true + } + + async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false):Promise { + const url = this.server.url + relUrl + let res + let timedOut = false + + if (type && type.includes('application/json')) { + body = JSON.stringify(body) + } else if (type && type.includes('application/x-www-form-urlencoded')) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(body || {})) { + params.set(key, value as any) + } + body = params.toString() + } + + Logger.log(`QUEUING ${verb} ${url}`) + + if (Capacitor.getPlatform() !== 'web') { + return this.sendRequestNative(verb, url, type, body, returnRawResponse) + } + + try { + res = await this.fetchQueue.add(() => { + Logger.log(`FETCHING ${verb} ${url}`) + return Promise.race([ + fetch(url, { + method: verb, + credentials: this.server.includeCredentials ? 'include' : 'omit', + headers: { + ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), + Authorization: 'Bearer ' + this.server.password, + }, + signal: this.abortSignal, + ...(body && !['get', 'head'].includes(verb.toLowerCase()) && { body }), + }), + new Promise((resolve, reject) => + setTimeout(() => { + timedOut = true + reject(new RequestTimeoutError()) + }, TIMEOUT) + ), + ]) + }) + } catch (e) { + if (timedOut) throw e + if (this.canceled) throw new CancelledSyncError() + console.log(e) + throw new NetworkError() + } + + Logger.log(`Receiving response for ${verb} ${url}`) + + if (res.redirected && !this.server.allowRedirects) { + throw new RedirectError() + } + + if (returnRawResponse) { + return res + } + + if (res.status === 401 || res.status === 403) { + throw new AuthenticationError() + } + if (res.status === 503 || res.status > 400) { + throw new HttpError(res.status, verb) + } + let json + try { + json = await res.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + + return json + } + + private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean) { + let res + let timedOut = false + try { + res = await this.fetchQueue.add(() => { + Logger.log(`FETCHING ${verb} ${url}`) + return Promise.race([ + Http.request({ + url, + method: verb, + disableRedirects: !this.server.allowRedirects, + headers: { + ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), + Authorization: 'Bearer ' + this.server.password, + }, + responseType: 'json', + ...(body && !['get', 'head'].includes(verb.toLowerCase()) && { data: body }), + }), + new Promise((resolve, reject) => + setTimeout(() => { + timedOut = true + reject(new RequestTimeoutError()) + }, TIMEOUT) + ), + ]) + }) + } catch (e) { + if (timedOut) throw e + console.log(e) + throw new NetworkError() + } + + Logger.log(`Receiving response for ${verb} ${url}`) + + if (res.status < 400 && res.status >= 300) { + throw new RedirectError() + } + + if (returnRawResponse) { + return res + } + + if (res.status === 401 || res.status === 403) { + throw new AuthenticationError() + } + if (res.status === 503 || res.status > 400) { + throw new HttpError(res.status, verb) + } + const json = res.data + + return json + } +} diff --git a/src/test/test.js b/src/test/test.js index 2bca73d2ff..e836874098 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -150,6 +150,12 @@ describe('Floccus', function() { password: random.float(), refreshToken: CREDENTIALS.password, }, + { + type: 'linkwarden', + url: SERVER, + serverFolder: 'Floccus-' + Math.random(), + ...CREDENTIALS, + }, ] before(async function() { @@ -359,7 +365,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const bookmark2 = await browser.bookmarks.create({ @@ -390,7 +397,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should update the server on local changes', async function() { @@ -895,7 +903,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should deduplicate unnormalized URLs without getting stuck', async function() { @@ -913,7 +922,7 @@ describe('Floccus', function() { url: 'http://nextcloud.com/' } const localMark2 = { - title: 'url', + title: 'url2', url: 'https://nextcloud.com' } const fooFolder = await browser.bookmarks.create({ @@ -957,7 +966,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should not fail when moving both folders and contents', async function() { @@ -1019,7 +1029,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should not fail when both moving folders and deleting their contents', async function() { @@ -1091,7 +1102,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should handle strange characters well', async function() { @@ -1380,7 +1392,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1406,7 +1419,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should move items successfully when mixing creation and moving (1)', async function() { @@ -1478,7 +1492,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1513,7 +1528,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should move items successfully when mixing creation and moving (2)', async function() { @@ -1602,7 +1618,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1646,7 +1663,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should move items without creating a folder loop', async function() { @@ -1716,7 +1734,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1724,7 +1743,8 @@ describe('Floccus', function() { expectTreeEqual( localTree, tree, - false + false, + Boolean(account.server.orderFolder) ) }) it('should integrate existing items from both sides', async function() { diff --git a/test/selenium-runner.js b/test/selenium-runner.js index 3b0ea7724e..c9a1aea2d1 100644 --- a/test/selenium-runner.js +++ b/test/selenium-runner.js @@ -81,12 +81,23 @@ installConsoleHandler() throw new Error('Unknown browser') } - testUrl += `dist/html/test.html?grep=${process.env.FLOCCUS_TEST}&server=http://${process.env.TEST_HOST}&app_version=${process.env.APP_VERSION}&browser=${process.env.SELENIUM_BROWSER}` + let server = `http://${process.env.TEST_HOST}` + + if (process.env.FLOCCUS_TEST.includes('linkwarden')) { + server = `https://cloud.linkwarden.app` + } + + testUrl += `dist/html/test.html?grep=${process.env.FLOCCUS_TEST}&server=${server}&app_version=${process.env.APP_VERSION}&browser=${process.env.SELENIUM_BROWSER}` if (process.env.FLOCCUS_TEST.includes('google-drive')) { testUrl += `&password=${process.env.GOOGLE_API_REFRESH_TOKEN}` } + if (process.env.FLOCCUS_TEST.includes('linkwarden')) { + testUrl += `&username=mk` + testUrl += `&password=${process.env.LINKWARDEN_TOKEN}` + } + if (process.env.FLOCCUS_TEST_SEED) { testUrl += `&seed=${process.env.FLOCCUS_TEST_SEED}` } From 8eb56ee1c65d147c07df8e53e82fed51239dd52e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 28 Aug 2024 15:09:04 +0200 Subject: [PATCH 02/13] test: Don't run benchmark tests on CI anymore Signed-off-by: Marcel Klehr --- .github/workflows/tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 342f5e9ae6..104ea1c93e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -103,7 +103,6 @@ jobs: - linkwarden test-name: - test - - benchmark root browsers: - firefox - chrome From 35aea4fe2bc16635c34870fd25b203c10c34a58b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 29 Aug 2024 09:06:09 +0200 Subject: [PATCH 03/13] fix(linkwarden): Don't accept browser internal URLs for now Signed-off-by: Marcel Klehr --- src/lib/adapters/Linkwarden.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/Linkwarden.ts b/src/lib/adapters/Linkwarden.ts index 13b0d1af8b..3a63c87ada 100644 --- a/src/lib/adapters/Linkwarden.ts +++ b/src/lib/adapters/Linkwarden.ts @@ -53,8 +53,12 @@ export default class LinkwardenAdapter implements Adapter, IResource): boolean { - return true + acceptsBookmark(bm: Bookmark):boolean { + try { + return ['https:', 'http:', 'ftp:', 'javascript:'].includes(new URL(bm.url).protocol) + } catch (e) { + return false + } } cancel(): void { From e58cc8cce2ae55a442124f703b5a6bccd16de633 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 31 Aug 2024 10:45:34 +0200 Subject: [PATCH 04/13] test(linkwarden): Massage tests for linkwarden towards passing Signed-off-by: Marcel Klehr --- src/test/test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/test.js b/src/test/test.js index e836874098..38dde48122 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -2355,6 +2355,9 @@ describe('Floccus', function() { if (ACCOUNT_DATA.noCache) { return this.skip() } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } expect( (await getAllBookmarks(account)).children ).to.have.lengthOf(0) @@ -3273,6 +3276,7 @@ describe('Floccus', function() { await account1.server.deleteFile(file.id) } } + await browser.bookmarks.removeTree(account1.getData().localRoot) await account1.delete() await browser.bookmarks.removeTree(account2.getData().localRoot) await account2.delete() @@ -3284,6 +3288,9 @@ describe('Floccus', function() { if (ACCOUNT_DATA.type === 'nextcloud-bookmarks' && ['v1.1.2', 'v2.3.4', 'stable3', 'stable4'].includes(APP_VERSION)) { return this.skip() } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } const localRoot = account1.getData().localRoot const fooFolder = await browser.bookmarks.create({ title: 'foo', @@ -4621,6 +4628,9 @@ describe('Floccus', function() { }) context('with tabs', function() { + if (ACCOUNT_DATA.type === 'linkwarden') { + return + } let account beforeEach('set up account', async function() { account = await Account.create(ACCOUNT_DATA) From f61f0175a0c44b21a120c807a32e40cce1206b32 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 31 Aug 2024 10:54:28 +0200 Subject: [PATCH 05/13] fix(tests) Signed-off-by: Marcel Klehr --- src/test/test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/test/test.js b/src/test/test.js index 38dde48122..74e53000b8 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -3276,9 +3276,17 @@ describe('Floccus', function() { await account1.server.deleteFile(file.id) } } - await browser.bookmarks.removeTree(account1.getData().localRoot) + try { + await browser.bookmarks.removeTree(account1.getData().localRoot) + } catch (e) { + // noop + } await account1.delete() - await browser.bookmarks.removeTree(account2.getData().localRoot) + try { + await browser.bookmarks.removeTree(account2.getData().localRoot) + } catch (e) { + // noop + } await account2.delete() }) it('should not sync two clients at the same time', async function() { From f11b598f9410ebb894a56627a0788b9d2b45b110 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 31 Aug 2024 11:12:06 +0200 Subject: [PATCH 06/13] tests: Don't test separators with linkwarden Signed-off-by: Marcel Klehr --- src/test/test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/test.js b/src/test/test.js index 74e53000b8..376aa915fa 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -1919,7 +1919,7 @@ describe('Floccus', function() { }) it('should leave alone unaccepted bookmarks entirely', async function() { if (!~ACCOUNT_DATA.type.indexOf('nextcloud')) { - this.skip() + return this.skip() } const localRoot = account.getData().localRoot @@ -2009,6 +2009,9 @@ describe('Floccus', function() { this.skip() return } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } const localRoot = account.getData().localRoot expect( @@ -2140,6 +2143,9 @@ describe('Floccus', function() { this.skip() return } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } const localRoot = account.getData().localRoot expect( From 8879e4a90100801a2c74cd560148f47bf9072c23 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 31 Aug 2024 11:31:44 +0200 Subject: [PATCH 07/13] fix(Linkwarden): Retry collection deletion if it fails Signed-off-by: Marcel Klehr --- src/lib/adapters/Linkwarden.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/adapters/Linkwarden.ts b/src/lib/adapters/Linkwarden.ts index 3a63c87ada..cc9eafe06f 100644 --- a/src/lib/adapters/Linkwarden.ts +++ b/src/lib/adapters/Linkwarden.ts @@ -157,7 +157,20 @@ export default class LinkwardenAdapter implements Adapter, IResource): Promise { Logger.log('(linkwarden)DELETEFOLDER', {folder}) - await this.sendRequest('DELETE', `/api/v1/collections/${folder.id}`) + let success = false + let count = 0 + do { + try { + count++ + await this.sendRequest('DELETE', `/api/v1/collections/${folder.id}`) + success = true + } catch (e) { + if (count > 3) { + throw e + } + // noop + } + } while (!success) } async getBookmarksTree(loadAll?: boolean): Promise> { From 33634416ff6559010024db9a6138ac05ae417268 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 1 Sep 2024 09:53:52 +0200 Subject: [PATCH 08/13] fix(Linkwarden): Pass if deleting collections fails with HTTP 401 Signed-off-by: Marcel Klehr --- src/lib/adapters/Linkwarden.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/Linkwarden.ts b/src/lib/adapters/Linkwarden.ts index cc9eafe06f..99ae764fda 100644 --- a/src/lib/adapters/Linkwarden.ts +++ b/src/lib/adapters/Linkwarden.ts @@ -165,7 +165,10 @@ export default class LinkwardenAdapter implements Adapter, IResource 3) { + if (e instanceof HttpError && e.status === 401) { + success = true + } + else if (count > 3) { throw e } // noop @@ -276,7 +279,7 @@ export default class LinkwardenAdapter implements Adapter, IResource 400) { From e27d23f96ec32594b51dbce57c1de4a80811a7a1 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 1 Sep 2024 10:13:45 +0200 Subject: [PATCH 09/13] fix(Unidirectional): Don't map reorders if server doesn't support orderFolder Signed-off-by: Marcel Klehr --- src/lib/strategies/Unidirectional.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/strategies/Unidirectional.ts b/src/lib/strategies/Unidirectional.ts index 1a5f87b3ba..0b83028a6a 100644 --- a/src/lib/strategies/Unidirectional.ts +++ b/src/lib/strategies/Unidirectional.ts @@ -152,13 +152,13 @@ export default class UnidirectionalSyncProcess extends DefaultStrategy { await this.executeRevert(target, this.revertPlan, this.direction, this.revertDonePlan, sourceScanResult.REORDER) - if (!this.revertReorders) { + if ('orderFolder' in this.server && !this.revertReorders) { const mappingsSnapshot = this.mappings.getSnapshot() Logger.log('Mapping reorderings') this.revertReorders = sourceScanResult.REORDER.map(mappingsSnapshot, this.direction) } - if ('orderFolder' in target) { + if ('orderFolder' in this.server && 'orderFolder' in target) { await this.executeReorderings(target, this.revertReorders) } } From 6d7b37732f184d50a0fe9a2359da60425082e961 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 1 Sep 2024 10:14:09 +0200 Subject: [PATCH 10/13] fix(tests): Don't test ordering on linkwarden Signed-off-by: Marcel Klehr --- src/test/test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/test.js b/src/test/test.js index 376aa915fa..6e94b3cff7 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -4423,6 +4423,9 @@ describe('Floccus', function() { ) }) it('should synchronize ordering', async function() { + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } expect( (await getAllBookmarks(account1)).children ).to.have.lengthOf(0) @@ -4520,7 +4523,10 @@ describe('Floccus', function() { // Skipping this, because nextcloud adapter currently // isn't able to track bookmarks across dirs, thus in this // scenario both bookmarks survive :/ - it.skip('should propagate moves using "last write wins"', async function() { + it('should propagate moves using "last write wins"', async function() { + if (ACCOUNT_DATA.type === 'nextcloud-bookmarks') { + return this.skip() + } const localRoot = account1.getData().localRoot const fooFolder = await browser.bookmarks.create({ title: 'foo', From b14571a5dc67424b8a9fa2b13b42e406f13d36d0 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 1 Sep 2024 13:21:58 +0200 Subject: [PATCH 11/13] fix(tests): Don't test ordering on linkwarden Signed-off-by: Marcel Klehr --- src/test/test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/test.js b/src/test/test.js index 6e94b3cff7..d6a491437c 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -2977,7 +2977,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should update the server on local changes', async function() { From 05b636eb241c5766385c287a53d051e45362a9b1 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 6 Sep 2024 09:24:05 +0200 Subject: [PATCH 12/13] fix(tests): Don't tetst for order when adapter doesn't have orderFolder Signed-off-by: Marcel Klehr --- src/test/test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/test.js b/src/test/test.js index d6a491437c..63675f83a8 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -826,7 +826,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should deduplicate unnormalized URLs', async function() { From 73716889f6a9baa732cf9efd702e76820501a883 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 6 Sep 2024 10:19:15 +0200 Subject: [PATCH 13/13] feat: Add UI for linkwarden Signed-off-by: Marcel Klehr --- _locales/en/messages.json | 22 ++- src/ui/components/OptionsLinkwarden.vue | 175 ++++++++++++++++++++++++ src/ui/components/native/Drawer.vue | 5 +- src/ui/store/actions.js | 15 ++ src/ui/store/definitions.js | 1 + src/ui/store/native/actions.js | 16 +++ src/ui/views/AccountOptions.vue | 8 +- src/ui/views/NewAccount.vue | 64 +++++++++ 8 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 src/ui/components/OptionsLinkwarden.vue diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 250733256b..e332c0d4bf 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -146,6 +146,9 @@ "DescriptionServerfolder": { "message": "When syncing, your bookmarks in this browser will be stored as links under this path on the server. Note, that this path represents a folder in the Nextcloud Bookmarks app, not a folder in Nextcloud Files. Leave this empty to just put all links in the topmost folder on the server." }, + "DescriptionServerfolderlinkwarden": { + "message": "When syncing, your bookmarks in this browser will be stored as links under this collection." + }, "LabelLocaltarget": { "message": "Local target" }, @@ -270,7 +273,7 @@ "message": "Nextcloud Bookmarks" }, "DescriptionAdapternextcloudfolders": { - "message": "The option 'Nextcloud Bookmarks' syncs your bookmarks with the Bookmarks app for Nextcloud. It can only sync http, ftp and javascript bookmarks. Make sure you have installed the Bookmarks app from the Nextcloud app store in your Nextcloud. This option cannot make use of end-to-end encryption." + "message": "Sync your bookmarks with the open-source Bookmarks app for Nextcloud (an open-source collaboration platform that you can either self-host or get an account for an instance in the cloud from one of the various hosters). With Nextcloud Bookmarks you can only sync http, ftp and javascript bookmarks. Make sure you have installed the Bookmarks app from the Nextcloud app store in your Nextcloud. This option cannot make use of end-to-end encryption." }, "LabelAdapternextcloud": { "message": "Nextcloud Bookmarks (legacy)" @@ -282,7 +285,7 @@ "message": "WebDAV share" }, "DescriptionAdapterwebdav": { - "message": "The WebDAV option syncs your bookmarks by storing them in a file in the provided WebDAV share. There is no accompanying web UI for this option and you can use it with any WebDAV-compatible server. It can sync http, ftp, data, file and javascript bookmarks. You can choose to use end-to-end encryption when using this option." + "message": "Sync your bookmarks by storing them in a file in the provided WebDAV share. There is no accompanying web UI for this option and you can use it with any WebDAV-compatible server, either selfhosted or in the cloud. It can sync http, ftp, data, file and javascript bookmarks. You can choose to use end-to-end encryption when using this option." }, "LabelAddaccount": { "message": "Add profile" @@ -776,5 +779,20 @@ }, "DescriptionReportproblem": { "message": "If you would like to directly contact the developers with a concrete issue, you can do so here:" + }, + "LabelAdapterlinkwarden": { + "message": "Linkwarden" + }, + "DescriptionAdapterlinkwarden": { + "message": "Sync your bookmarks with the open-source Linkwarden app, either hosted on your own server or in the cloud at cloud.linkwarden.app. It can only sync http, ftp and javascript bookmarks. This option cannot make use of end-to-end encryption." + }, + "LabelLinkwardenurl": { + "message": "The URL of your Linkwarden server" + }, + "LabelAccesstoken": { + "message": "Access token" + }, + "LabelLinkwardenconnectionerror": { + "message": "Failed to connect to your Linkwarden server" } } diff --git a/src/ui/components/OptionsLinkwarden.vue b/src/ui/components/OptionsLinkwarden.vue new file mode 100644 index 0000000000..f77176cb07 --- /dev/null +++ b/src/ui/components/OptionsLinkwarden.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/ui/components/native/Drawer.vue b/src/ui/components/native/Drawer.vue index 22488745e7..c49ece7249 100644 --- a/src/ui/components/native/Drawer.vue +++ b/src/ui/components/native/Drawer.vue @@ -81,10 +81,11 @@ export default { filters: { accountIcon(type) { const icons = { - 'google-drive': 'mdi-google-drive', 'nextcloud-bookmarks': 'mdi-cloud', + 'linkwarden': 'mdi-link-box-variant-outline', 'webdav': 'mdi-folder-network', - 'git': 'mdi-source-repository' + 'git': 'mdi-source-repository', + 'google-drive': 'mdi-google-drive' } return icons[type] }, diff --git a/src/ui/store/actions.js b/src/ui/store/actions.js index 2ee9fe9518..a7700c89a7 100644 --- a/src/ui/store/actions.js +++ b/src/ui/store/actions.js @@ -125,6 +125,21 @@ export const actionsDefinition = { } return true }, + async [actions.TEST_LINKWARDEN_SERVER]({commit, dispatch, state}, {rootUrl, token}) { + await dispatch(actions.REQUEST_NETWORK_PERMISSIONS) + let res = await fetch(`${rootUrl}/api/v1/collections`, { + method: 'GET', + credentials: 'omit', + headers: { + 'User-Agent': 'Floccus bookmarks sync', + Authorization: 'Bearer ' + token, + } + }) + if (res.status !== 200) { + throw new Error(browser.i18n.getMessage('LabelLinkwardenconnectionerror')) + } + return true + }, async [actions.START_LOGIN_FLOW]({commit, dispatch, state}, rootUrl) { commit(mutations.SET_LOGIN_FLOW_STATE, true) let res = await fetch(`${rootUrl}/index.php/login/v2`, {method: 'POST', headers: {'User-Agent': 'Floccus bookmarks sync'}}) diff --git a/src/ui/store/definitions.js b/src/ui/store/definitions.js index def17e734e..7322b91197 100644 --- a/src/ui/store/definitions.js +++ b/src/ui/store/definitions.js @@ -30,6 +30,7 @@ export const actions = { EXPORT_BOOKMARKS: 'EXPORT_BOOKMARKS', TEST_WEBDAV_SERVER: 'TEST_WEBDAV_SERVER', TEST_NEXTCLOUD_SERVER: 'TEST_NEXTCLOUD_SERVER', + TEST_LINKWARDEN_SERVER: 'TEST_LINKWARDEN_SERVER', START_LOGIN_FLOW: 'START_LOGIN_FLOW', STOP_LOGIN_FLOW: 'STOP_LOGIN_FLOW', REQUEST_NETWORK_PERMISSIONS: 'REQUEST_NETWORK_PERMISSIONS', diff --git a/src/ui/store/native/actions.js b/src/ui/store/native/actions.js index 470aefb7a6..d2deac9ba6 100644 --- a/src/ui/store/native/actions.js +++ b/src/ui/store/native/actions.js @@ -9,6 +9,7 @@ import { Share } from '@capacitor/share' import Html from '../../../lib/serializers/Html' import { Bookmark, Folder } from '../../../lib/Tree' import { Browser } from '@capacitor/browser' +import browser from '../../../lib/browser-api' export const actionsDefinition = { async [actions.LOAD_ACCOUNTS]({ commit, dispatch, state }) { @@ -202,6 +203,21 @@ export const actionsDefinition = { // noop, because capacitor Http doesn't support PROPFIND return true }, + async [actions.TEST_LINKWARDEN_SERVER]({commit, dispatch, state}, {rootUrl, token}) { + await dispatch(actions.REQUEST_NETWORK_PERMISSIONS) + let res = await Http.request({ + url: `${rootUrl}/api/v1/collections`, + method: 'GET', + headers: { + 'User-Agent': 'Floccus bookmarks sync', + Authorization: 'Bearer ' + token, + } + }) + if (res.status !== 200) { + throw new Error(browser.i18n.getMessage('LabelLinkwardenconnectionerror')) + } + return true + }, async [actions.TEST_NEXTCLOUD_SERVER]({commit, dispatch, state}, rootUrl) { let res = await Http.request({ url: `${rootUrl}/index.php/login/v2`, diff --git a/src/ui/views/AccountOptions.vue b/src/ui/views/AccountOptions.vue index 3e272a90ab..a3f50b03e4 100644 --- a/src/ui/views/AccountOptions.vue +++ b/src/ui/views/AccountOptions.vue @@ -138,6 +138,11 @@ v-bind.sync="data" @reset="onReset" @delete="onDelete" /> + + + + + + @@ -398,6 +443,7 @@ export default { refreshToken: '', bookmark_file: 'bookmarks.xbel', bookmark_file_type: 'xbel', + serverFolder: 'Floccus', serverRoot: '', localRoot: null, syncInterval: 15, @@ -415,6 +461,11 @@ export default { label: this.t('LabelAdapternextcloudfolders'), description: this.t('DescriptionAdapternextcloudfolders') }, + { + type: 'linkwarden', + label: this.t('LabelAdapterlinkwarden'), + description: this.t('DescriptionAdapterlinkwarden') + }, { type: 'webdav', label: this.t('LabelAdapterwebdav'), @@ -461,6 +512,7 @@ export default { enabled: this.enabled, label: this.label, ...(this.adapter === 'nextcloud-bookmarks' && {serverRoot: this.serverRoot, clickCountEnabled: this.clickCountEnabled}), + ...(this.adapter === 'linkwarden' && {serverFolder: this.serverFolder}), ...(this.adapter === 'git' && {branch: this.branch}), ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'git') && {bookmark_file: this.bookmark_file}), ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'git') && {bookmark_file_type: this.bookmark_file_type}), @@ -491,6 +543,18 @@ export default { } this.isServerTestRunning = false }, + async testLinkwardenServer() { + this.isServerTestRunning = true + this.serverTestError = '' + try { + await this.$store.dispatch(actions.TEST_LINKWARDEN_SERVER, {rootUrl: this.server, username: this.username, token: this.password}) + this.serverTestSuccessful = true + this.currentStep++ + } catch (e) { + this.serverTestError = e.message + } + this.isServerTestRunning = false + }, async testWebdavServer() { this.isServerTestRunning = true this.serverTestError = ''