diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d90a91b883..104ea1c93e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,9 +100,9 @@ jobs: - git-html - google-drive - google-drive-encrypted + - linkwarden test-name: - test - - benchmark root browsers: - firefox - chrome @@ -253,6 +253,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/_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/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..99ae764fda --- /dev/null +++ b/src/lib/adapters/Linkwarden.ts @@ -0,0 +1,350 @@ +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(bm: Bookmark):boolean { + try { + return ['https:', 'http:', 'ftp:', 'javascript:'].includes(new URL(bm.url).protocol) + } catch (e) { + return false + } + } + + 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}) + let success = false + let count = 0 + do { + try { + count++ + await this.sendRequest('DELETE', `/api/v1/collections/${folder.id}`) + success = true + } catch (e) { + if (e instanceof HttpError && e.status === 401) { + success = true + } + else if (count > 3) { + throw e + } + // noop + } + } while (!success) + } + + 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 === 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/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) } } diff --git a/src/test/test.js b/src/test/test.js index 2bca73d2ff..63675f83a8 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() { @@ -818,7 +826,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should deduplicate unnormalized URLs', async function() { @@ -895,7 +904,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should deduplicate unnormalized URLs without getting stuck', async function() { @@ -913,7 +923,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 +967,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should not fail when moving both folders and contents', async function() { @@ -1019,7 +1030,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 +1103,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should handle strange characters well', async function() { @@ -1380,7 +1393,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1406,7 +1420,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 +1493,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1513,7 +1529,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 +1619,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1646,7 +1664,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should move items without creating a folder loop', async function() { @@ -1716,7 +1735,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) const localTree = await account.localTree.getBookmarksTree(true) @@ -1724,7 +1744,8 @@ describe('Floccus', function() { expectTreeEqual( localTree, tree, - false + false, + Boolean(account.server.orderFolder) ) }) it('should integrate existing items from both sides', async function() { @@ -1899,7 +1920,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 @@ -1989,6 +2010,9 @@ describe('Floccus', function() { this.skip() return } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } const localRoot = account.getData().localRoot expect( @@ -2120,6 +2144,9 @@ describe('Floccus', function() { this.skip() return } + if (ACCOUNT_DATA.type === 'linkwarden') { + return this.skip() + } const localRoot = account.getData().localRoot expect( @@ -2335,6 +2362,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) @@ -2948,7 +2978,8 @@ describe('Floccus', function() { }) ] }), - false + false, + Boolean(account.server.orderFolder) ) }) it('should update the server on local changes', async function() { @@ -3253,8 +3284,17 @@ describe('Floccus', function() { await account1.server.deleteFile(file.id) } } + 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() { @@ -3264,6 +3304,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', @@ -4382,6 +4425,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) @@ -4479,7 +4525,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', @@ -4601,6 +4650,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) 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 = '' 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}` }