Skip to content

Commit

Permalink
feat: Implement linkwarden sync
Browse files Browse the repository at this point in the history
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
  • Loading branch information
marcelklehr committed Aug 28, 2024
1 parent 63fca7e commit 3a4be19
Show file tree
Hide file tree
Showing 5 changed files with 380 additions and 16 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
- git-html
- google-drive
- google-drive-encrypted
- linkwarden
test-name:
- test
- benchmark root
Expand Down Expand Up @@ -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
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
330 changes: 330 additions & 0 deletions src/lib/adapters/Linkwarden.ts
Original file line number Diff line number Diff line change
@@ -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<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(bookmark: Bookmark<TItemLocation>): 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<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

0 comments on commit 3a4be19

Please sign in to comment.