Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement linkwarden sync #1709

Merged
merged 13 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ jobs:
- git-html
- google-drive
- google-drive-encrypted
- linkwarden
test-name:
- test
- benchmark root
browsers:
- firefox
- chrome
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
334 changes: 334 additions & 0 deletions src/lib/adapters/Linkwarden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
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<typeof ItemLocation.SERVER> {
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<typeof ItemLocation.SERVER>):boolean {
try {
return ['https:', 'http:', 'ftp:', 'javascript:'].includes(new URL(bm.url).protocol)
Dismissed Show dismissed Hide dismissed
} 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<void> {
return Promise.resolve(undefined)
}

onSyncFail(): Promise<void> {
return Promise.resolve(undefined)
}

onSyncStart(needLock?: boolean, forceLock?: boolean): Promise<void | boolean> {
this.canceled = false
return Promise.resolve(undefined)
}

async createBookmark(bookmark: Bookmark<typeof ItemLocation.SERVER>): Promise<string | number> {
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<typeof ItemLocation.SERVER>): Promise<void> {
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<typeof ItemLocation.SERVER>): Promise<void> {
Logger.log('(linkwarden)DELETE', {bookmark})
await this.sendRequest('DELETE', `/api/v1/links/${bookmark.id}`)
}

async createFolder(folder: Folder<typeof ItemLocation.SERVER>): Promise<string | number> {
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<typeof ItemLocation.SERVER>): Promise<void> {
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<typeof ItemLocation.SERVER>): Promise<void> {
Logger.log('(linkwarden)DELETEFOLDER', {folder})
await this.sendRequest('DELETE', `/api/v1/collections/${folder.id}`)
}

async getBookmarksTree(loadAll?: boolean): Promise<Folder<typeof ItemLocation.SERVER>> {
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<boolean> {
return true
}

async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false):Promise<any> {
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
}
}
Loading
Loading