diff --git a/src/errors/Error.ts b/src/errors/Error.ts index 017d6970c1..a19afbe26b 100644 --- a/src/errors/Error.ts +++ b/src/errors/Error.ts @@ -1,4 +1,4 @@ -import { Bookmark, TItemLocation } from '../lib/Tree' +import { Bookmark, TItem, TItemLocation } from '../lib/Tree' import { statusCodes } from '../lib/statusCodes' export class FloccusError extends Error { @@ -160,14 +160,22 @@ export class HttpError extends TransientError { public status: number public method: string public statusMessage: string + public item: TItem|undefined|null - constructor(status: number, method: string) { + constructor(status: number, method: string, item?: TItem) { super( - `E019: HTTP status ${status}. Failed ${method} request (${statusCodes[status]}). Check your server configuration and log.` + `E019: HTTP status ${status}. Failed ${method} request (${statusCodes[status]})` + + (item + ? ` for item #${item.id}[${item.title.substring(0, 100)}]${ + 'url' in item ? `(${item.url.substring(0, 100)})` : '' + } parentId: ${item.parentId}` + : '') + + `. Check your server configuration and log.` ) this.status = status this.method = method this.statusMessage = statusCodes[status] + this.item = item Object.setPrototypeOf(this, HttpError.prototype) } } diff --git a/src/lib/adapters/Karakeep.ts b/src/lib/adapters/Karakeep.ts index 95d319d943..da07380ad8 100644 --- a/src/lib/adapters/Karakeep.ts +++ b/src/lib/adapters/Karakeep.ts @@ -1,5 +1,5 @@ import Adapter from '../interfaces/Adapter' -import { Bookmark, Folder, ItemLocation } from '../Tree' +import { Bookmark, Folder, ItemLocation, TItem, TItemLocation } from '../Tree' import PQueue from 'p-queue' import { ICapabilities, IHashSettings, IResource } from '../interfaces/Resource' import Logger from '../Logger' @@ -118,12 +118,7 @@ export default class KarakeepAdapter implements Adapter, IResource { + async createBookmark(bookmark: Bookmark): Promise { Logger.log('(karakeep)CREATE', { bookmark }) const response = await this.sendRequest( 'POST', @@ -133,7 +128,9 @@ export default class KarakeepAdapter implements Adapter, IResource { + async updateBookmark(bookmark: Bookmark): Promise { Logger.log('(karakeep)UPDATE', { bookmark }) const [id, oldParentId] = this.parseBookmarkId(bookmark.id) await this.sendRequest( @@ -170,7 +165,9 @@ export default class KarakeepAdapter implements Adapter, IResource { + async removeBookmark(bookmark: Bookmark): Promise { Logger.log('(karakeep)DELETE', { bookmark }) const [id, parentId] = this.parseBookmarkId(bookmark.id) - // Remove the bookmark from the list - await this.sendRequest( - 'DELETE', - `/api/v1/lists/${parentId}/bookmarks/${id}`, - 'application/json', - undefined, - /* returnRawResponse */ true - ) - - // If the bookmark is not in any list, delete it from the server - const bookmarkLists = await this.getListsOfBookmark(id) - if (bookmarkLists.size === 0) { + try { + // Remove the bookmark from the list await this.sendRequest( 'DELETE', - `/api/v1/bookmarks/${id}`, + `/api/v1/lists/${parentId}/bookmarks/${id}`, 'application/json', undefined, - /* returnRawResponse */ true + true, + bookmark ) + + // If the bookmark is not in any list, delete it from the server + const bookmarkLists = await this.getListsOfBookmark(id) + if (bookmarkLists.size === 0) { + await this.sendRequest( + 'DELETE', + `/api/v1/bookmarks/${id}`, + 'application/json', + undefined, + true, + bookmark + ) + } + } catch (e) { + if (e instanceof HttpError) { + if (e.status === 404) { + return + } + } + throw e } } - async createFolder(folder: { - id: string | number - title?: string - parentId: string | number - }): Promise { + async createFolder(folder: Folder): Promise { Logger.log('(karakeep)CREATEFOLDER', { folder }) const response = await this.sendRequest( 'POST', @@ -239,16 +242,14 @@ export default class KarakeepAdapter implements Adapter, IResource { + async updateFolder(folder: Folder): Promise { Logger.log('(karakeep)UPDATEFOLDER', { folder }) await this.sendRequest( 'PATCH', @@ -257,14 +258,16 @@ export default class KarakeepAdapter implements Adapter, IResource { + async removeFolder(folder: Folder): Promise { Logger.log('(karakeep)DELETEFOLDER', { folder }) const deleteListContent = async() => { @@ -276,17 +279,27 @@ export default class KarakeepAdapter implements Adapter, IResource b.id)) } while (nextCursor !== null) await Promise.all( bookmarkIds.map((id) => - this.removeBookmark({ - id: `${id};${folder.id}`, - parentId: folder.id, - }) + // create a dummy bookmark + this.removeBookmark( + new Bookmark({ + id: `${id};${folder.id}`, + parentId: folder.id, + url: 'about:blank', + title: '', + location: ItemLocation.SERVER, + }) + ) ) ) } @@ -300,9 +313,13 @@ export default class KarakeepAdapter implements Adapter, IResource - this.removeFolder({ + // create dummy folder + this.removeFolder(new Folder({ id: listId, - }) + parentId: folder.id, + title: '', + location: ItemLocation.SERVER, + })) ) ) } @@ -310,13 +327,23 @@ export default class KarakeepAdapter implements Adapter, IResource = null ): Promise { const url = this.server.url + relUrl let res @@ -447,7 +475,7 @@ export default class KarakeepAdapter implements Adapter, IResource= 400) { - throw new HttpError(res.status, verb) + Logger.log( + `${verb} ${url}: Server responded with ${res.status}: ` + + (await res.text()).substring(0, 250) + ) + throw new HttpError(res.status, verb, item) + } + + if (returnRawResponse) { + return res } + let json try { json = await res.json() @@ -512,7 +545,8 @@ export default class KarakeepAdapter implements Adapter, IResource = null ) { let res let timedOut = false @@ -561,7 +595,18 @@ export default class KarakeepAdapter implements Adapter, IResource= 400) { - throw new HttpError(res.status, verb) + let responseData: string + try { + responseData = + typeof res.data === 'string' ? res.data : JSON.stringify(res.data) + } catch (e) { + responseData = String(res.data) + } + Logger.log( + `${verb} ${url}: Server responded with ${res.status}: ` + + responseData.substring(0, 250) + ) + throw new HttpError(res.status, verb, item) } const json = res.data diff --git a/src/lib/adapters/Linkwarden.ts b/src/lib/adapters/Linkwarden.ts index acf1efe0ee..10df41c473 100644 --- a/src/lib/adapters/Linkwarden.ts +++ b/src/lib/adapters/Linkwarden.ts @@ -1,5 +1,5 @@ import Adapter from '../interfaces/Adapter' -import { Bookmark, Folder, ItemLocation } from '../Tree' +import { Bookmark, Folder, ItemLocation, TItem, TItemLocation } from '../Tree' import PQueue from 'p-queue' import { ICapabilities, IHashSettings, IResource } from '../interfaces/Resource' import Logger from '../Logger' @@ -118,7 +118,10 @@ export default class LinkwardenAdapter implements Adapter, IResource): Promise { Logger.log('(linkwarden)DELETE', {bookmark}) - await this.sendRequest('DELETE', `/api/v1/links/${bookmark.id}`) + try { + await this.sendRequest('DELETE', `/api/v1/links/${bookmark.id}`, undefined, undefined, false, bookmark) + } catch (e) { + if (e instanceof HttpError) { + if (e.status === 404) { + return + } + } + throw e + } } async createFolder(folder: Folder): Promise { @@ -154,7 +169,10 @@ export default class LinkwardenAdapter implements Adapter, IResource): Promise { @@ -178,7 +199,7 @@ export default class LinkwardenAdapter implements Adapter, IResource { + async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false, item: TItem = null):Promise { const url = this.server.url + relUrl let res let timedOut = false @@ -259,7 +280,7 @@ export default class LinkwardenAdapter implements Adapter, IResource= 400) { - throw new HttpError(res.status, verb) + Logger.log( + `${verb} ${url}: Server responded with ${res.status}: ` + + (await res.text()).substring(0, 250) + ) + throw new HttpError(res.status, verb, item) } let json try { @@ -317,7 +342,7 @@ export default class LinkwardenAdapter implements Adapter, IResource = null) { let res let timedOut = false try { @@ -363,7 +388,18 @@ export default class LinkwardenAdapter implements Adapter, IResource= 400) { - throw new HttpError(res.status, verb) + let responseData: string + try { + responseData = + typeof res.data === 'string' ? res.data : JSON.stringify(res.data) + } catch (e) { + responseData = String(res.data) + } + Logger.log( + `${verb} ${url}: Server responded with ${res.status}: ` + + responseData.substring(0, 250) + ) + throw new HttpError(res.status, verb, item) } const json = res.data diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index bf942c5ab5..c1d5259568 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -3,7 +3,7 @@ import { CapacitorHttp as Http } from '@capacitor/core' import Adapter from '../interfaces/Adapter' import HtmlSerializer from '../serializers/Html' import Logger from '../Logger' -import { Bookmark, Folder, ItemLocation, TItem } from '../Tree' +import { Bookmark, Folder, ItemLocation, TItem, TItemLocation } from '../Tree' import { Base64 } from 'js-base64' import AsyncLock from 'async-lock' import * as Parallel from 'async-parallel' @@ -430,7 +430,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'POST', 'index.php/apps/bookmarks/public/rest/v2/folder', 'application/json', - body + body, + undefined, + undefined, + folder, ) if (typeof json.item !== 'object') { throw new UnexpectedServerResponseError() @@ -475,7 +478,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'POST', `index.php/apps/bookmarks/public/rest/v2/folder/${parentId}/import`, 'multipart/form-data', - body + body, + undefined, + undefined, + folder ) }) @@ -528,7 +534,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'PUT', `index.php/apps/bookmarks/public/rest/v2/folder/${id}`, 'application/json', - body + body, + undefined, + undefined, + folder ) const oldParentFolder = this.tree.findFolder(oldFolder.parentId) if (!oldParentFolder) { @@ -573,7 +582,12 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } await this.sendRequest( 'DELETE', - `index.php/apps/bookmarks/public/rest/v2/folder/${id}` + `index.php/apps/bookmarks/public/rest/v2/folder/${id}`, + undefined, + undefined, + undefined, + undefined, + folder, ) const parent = this.tree.findFolder(oldFolder.parentId) if (parent) { @@ -679,7 +693,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'POST', 'index.php/apps/bookmarks/public/rest/v2/bookmark', 'application/json', - body + body, + undefined, + undefined, + bm ) } catch (e) { if (e instanceof HttpError) { @@ -740,7 +757,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'PUT', `index.php/apps/bookmarks/public/rest/v2/bookmark/${upstreamId}`, 'application/json', - body + body, + undefined, + undefined, + newBm, ) } catch (e) { if (e instanceof HttpError) { @@ -780,7 +800,12 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes try { await this.sendRequest( 'DELETE', - `index.php/apps/bookmarks/public/rest/v2/folder/${parentId}/bookmarks/${upstreamId}` + `index.php/apps/bookmarks/public/rest/v2/folder/${parentId}/bookmarks/${upstreamId}`, + undefined, + undefined, + undefined, + undefined, + bookmark, ) // remove bookmark from the cached list const list = await this.getBookmarksList() @@ -851,7 +876,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return json.ocs.data } - async sendRequest(verb:string, relUrl:string, type:string = null, originalBody:any = null, returnRawResponse = false, headers = {}):Promise { + async sendRequest(verb:string, relUrl:string, type:string = null, originalBody:any = null, returnRawResponse = false, headers = {}, item: TItem = null):Promise { const url = this.normalizeServerURL(this.server.url) + relUrl let res let timedOut = false @@ -870,7 +895,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes Logger.log(`QUEUING ${verb} ${url}`) if (!IS_BROWSER) { - return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) + return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers, item) } const authString = !this.ticket || this.ticketTimestamp + 60 * 60 * 1000 < Date.now() @@ -920,7 +945,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes if ((res.status === 401 || res.status === 403 || res.status === 404) && authString.startsWith('Bearer')) { this.ticket = null this.ticketTimestamp = 0 - return this.sendRequest(verb, relUrl, type, originalBody, returnRawResponse, headers) + return this.sendRequest(verb, relUrl, type, originalBody, returnRawResponse, headers, item) } if (returnRawResponse) { @@ -932,7 +957,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } if (res.status === 503 || res.status >= 400) { Logger.log(`${verb} ${url}: Server responded with ${res.status}: ` + (await res.text()).substring(0, 250)) - throw new HttpError(res.status, verb) + throw new HttpError(res.status, verb, item) } let json try { @@ -992,7 +1017,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return res.status === 200 } - private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean, headers = {}) { + private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean, headers = {}, item: TItem = null) { let res let timedOut = false const authString = !this.ticket || this.ticketTimestamp + 60 * 60 * 1000 < Date.now() @@ -1037,7 +1062,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes if ((res.status === 401 || res.status === 403 || res.status === 404) && authString.startsWith('Bearer')) { this.ticket = null this.ticketTimestamp = 0 - return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) + return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers, item) } if (returnRawResponse) { @@ -1052,7 +1077,17 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new AuthenticationError() } if (res.status === 503 || res.status >= 400) { - throw new HttpError(res.status, verb) + let responseData: string + try { + responseData = typeof res.data === 'string' ? res.data : JSON.stringify(res.data) + } catch (e) { + responseData = String(res.data) + } + Logger.log( + `${verb} ${url}: Server responded with ${res.status}: ` + + responseData.substring(0, 250) + ) + throw new HttpError(res.status, verb, item) } const json = res.data if (json.status !== 'success') { diff --git a/src/test/test.js b/src/test/test.js index 20230f488d..ba340157a7 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -16,6 +16,7 @@ import { ClientsideDeletionFailsafeError, ServersideAdditionFailsafeError, ServersideDeletionFailsafeError } from '../errors/Error' +import Logger from '../lib/Logger' chai.use(chaiAsPromised) const expect = chai.expect @@ -72,7 +73,7 @@ describe('Floccus', function() { this.slow(20000) // 20s is slow const params = (new URL(window.location.href)).searchParams - let SERVER, CREDENTIALS, ACCOUNTS, APP_VERSION, SEED, BROWSER, RANDOM_MANIPULATION_ITERATIONS, TEST_URL + let SERVER, CREDENTIALS, ACCOUNTS, APP_VERSION, SEED, BROWSER, RANDOM_MANIPULATION_ITERATIONS, TEST_URL, IS_CI SERVER = params.get('server') || 'http://localhost' @@ -83,6 +84,7 @@ describe('Floccus', function() { } APP_VERSION = params.get('app_version') || 'stable' BROWSER = params.get('browser') || 'firefox' + IS_CI = params.get('ci') === 'true' SEED = (new URL(window.location.href)).searchParams.get('seed') || Math.random() + '' console.log('RANDOMNESS SEED', SEED) @@ -235,6 +237,13 @@ describe('Floccus', function() { if (account) { let localRoot = account.getData().localRoot if (localRoot) await browser.bookmarks.removeTree(localRoot) + // Dump logs if test failed + if (IS_CI && this.currentTest.isFailed()) { + const logs = await Logger.getLogs() + for (const log of logs) { + console.log(log) + } + } await account.delete() } }) @@ -7184,6 +7193,13 @@ describe('Floccus', function() { await account1.delete() await browser.bookmarks.removeTree(account2.getData().localRoot) await account2.delete() + // Dump logs if test failed + if (IS_CI && this.currentTest.isFailed()) { + const logs = await Logger.getLogs() + for (const log of logs) { + console.log(log) + } + } }) it('should handle deep hierarchies with lots of bookmarks', async function() { diff --git a/src/ui/views/native/Home.vue b/src/ui/views/native/Home.vue index 668ae360bc..a83362a993 100644 --- a/src/ui/views/native/Home.vue +++ b/src/ui/views/native/Home.vue @@ -1,13 +1,14 @@