diff --git a/.github/workflows/android-appium.yml b/.github/workflows/android-appium.yml index 8955b50baa..e0f50757da 100644 --- a/.github/workflows/android-appium.yml +++ b/.github/workflows/android-appium.yml @@ -89,7 +89,7 @@ jobs: android-appium: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 80 needs: init env: diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 966a7b552f..d37af58399 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -960,6 +960,15 @@ "LabelParentfolder": { "message": "Parent folder" }, + "LabelTags": { + "message": "Tags" + }, + "DescriptionTags": { + "message": "Type a tag and press enter" + }, + "LabelSearchbytag": { + "message": "Search bookmarks tagged {0}" + }, "LabelHome": { "message": "Home" }, diff --git a/doc/Adapters.md b/doc/Adapters.md index 89e5705885..81e62c5b10 100644 --- a/doc/Adapters.md +++ b/doc/Adapters.md @@ -109,6 +109,21 @@ class Resource { */ async removeFolder(id: int) + /** + * @return Promise what this resource can do: + * { + * preserveOrder: boolean, // can store the order of children + * hashFn: ('sha256'|'murmur3'|'xxhash3')[], // supported hash functions + * supportsTags: boolean, // can store and return Bookmark#tags + * } + * + * Tags are only synced if *both* resources of a sync report `supportsTags`. + * If they do, `Bookmark#tags` takes part in hashing, so tag-only changes bump + * folder hashes and aren't skipped over -- servers that compute folder hashes + * themselves have to include tags in them too (see NextcloudBookmarks#_getFolderHash). + */ + async getCapabilities() : ICapabilities + /** * ------ * The following methods are optional @@ -143,8 +158,12 @@ class Bookmark { public parentId: int public url: string public title: string + // Only meaningful for adapters that report `supportsTags: true`. `undefined` + // means "this resource has nothing to say about tags" and is never treated as + // "no tags", so an adapter without tag support can't wipe the other side's. + public tags: string[]|undefined - constructor({ id: int, parentId: int, url: string, title: string }) + constructor({ id: int, parentId: int, url: string, title: string, tags?: string[] }) clone() : Bookmark } diff --git a/doiuse-report.baseline.txt b/doiuse-report.baseline.txt index cdadc26ec4..be5f5af38c 100644 --- a/doiuse-report.baseline.txt +++ b/doiuse-report.baseline.txt @@ -2,6 +2,8 @@ +108:1: CSS overflow property only partially supported by: Safari on iOS (11.0-11.2), QQ Browser (14.9) (css-overflow) +112:1: CSS scrollbar styling not supported by: Safari on iOS (16.6-16.7,18.5-18.7) and only partially supported by: Chrome (103-104,109,111-112,116-118,120), Safari on iOS (11.0-11.2), Android Browser (149), QQ Browser (14.9) (css-scrollbar) 1:10179: CSS caret-color not supported by: Safari on iOS (11.0-11.2) (css-caret-color) 1:10389: CSS caret-color not supported by: Safari on iOS (11.0-11.2) (css-caret-color) 1:10599: CSS caret-color not supported by: Safari on iOS (11.0-11.2) (css-caret-color) diff --git a/src/lib/LocalTabs.ts b/src/lib/LocalTabs.ts index 551cc27636..6b4a035ad5 100644 --- a/src/lib/LocalTabs.ts +++ b/src/lib/LocalTabs.ts @@ -607,6 +607,7 @@ export default class LocalTabs implements OrderFolderResource async diffBookmark(oldBookmark:Bookmark, newBookmark:Bookmark):Promise { let hasChanged if (this.checkHashes) { + // With tag syncing on, the hash covers tags as well hasChanged = await this.bookmarkHasChanged(oldBookmark, newBookmark) } else { - hasChanged = oldBookmark.title !== newBookmark.title || oldBookmark.url !== newBookmark.url + hasChanged = + oldBookmark.title !== newBookmark.title || + oldBookmark.url !== newBookmark.url || + Boolean(this.hashSettings?.syncTags && !tagsEqual(oldBookmark.tags, newBookmark.tags)) } if (hasChanged) { this.result.UPDATE.commit({ type: ActionType.UPDATE, payload: newBookmark, oldItem: oldBookmark }) diff --git a/src/lib/Tree.ts b/src/lib/Tree.ts index 24ad6defb0..109d705bdc 100644 --- a/src/lib/Tree.ts +++ b/src/lib/Tree.ts @@ -32,6 +32,73 @@ interface IItemIndex { let HASH_ITERATIONS = 0 +/** + * Bring a bookmark's tags into canonical form: strings only, trimmed, no empty + * entries, no duplicates, sorted. + * + * Sorting is what makes tags behave like the set they are: the order a server + * hands them back in is its own business (Linkwarden orders by tag identity, + * Nextcloud by whatever the database feels like), so without a canonical order + * a pure reordering would hash differently and register as a change. + * + * The sort has to agree byte for byte with Nextcloud Bookmarks, which sorts + * with `sort($tags, SORT_STRING)` before hashing `{title, url, tags}` for its + * server-side folder hashes. Plain `.sort()` is UTF-16 code-unit order, which + * matches that across the BMP. Do NOT switch this to `localeCompare` -- it is + * locale-dependent and would silently stop matching, leaving folders looking + * changed on every sync. (Astral characters do diverge from PHP's byte order; + * the cost is a needlessly re-fetched folder, never wrong data.) + * + * `undefined` means "this resource didn't tell us anything about tags" and is + * preserved as such, so we never mistake a silent adapter for "all tags removed". + */ +export function normalizeTags(tags?: string[]): string[] | undefined { + if (!Array.isArray(tags)) { + return undefined + } + const seen = new Set() + const normalized = [] + for (const tag of tags) { + if (typeof tag !== 'string') { + continue + } + const trimmed = tag.trim() + if (!trimmed || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + normalized.push(trimmed) + } + return normalized.sort() +} + +/** + * Cache slot for a memoized hash. Every setting that changes the hashed bytes + * has to be part of it, or a sync that negotiated different settings would read + * back a stale value. + */ +function hashCacheKey({ preserveOrder, hashFn, syncTags }: IHashSettings): string { + return `${preserveOrder}-${hashFn}-${Boolean(syncTags)}` +} + +/** + * Compare two tag lists as sets: tags are unordered by nature, so a mere + * reordering must not count as a change. + */ +export function tagsEqual(tags1?: string[], tags2?: string[]): boolean { + const set1 = new Set(tags1 || []) + const set2 = new Set(tags2 || []) + if (set1.size !== set2.size) { + return false + } + for (const tag of set1) { + if (!set2.has(tag)) { + return false + } + } + return true +} + export class Bookmark { public type = ItemType.BOOKMARK public id: string | number @@ -62,7 +129,7 @@ export class Bookmark { this.id = id this.parentId = parentId this.title = title - this.tags = tags + this.tags = normalizeTags(tags) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.location = location || ItemLocation.LOCAL @@ -101,33 +168,40 @@ export class Bookmark { } setHashCacheValue(hashSettings: IHashSettings, value: string): void { - const cacheKey = `${hashSettings.preserveOrder}-${hashSettings.hashFn}` + const cacheKey = hashCacheKey(hashSettings) if (!this.hashValue) this.hashValue = {} this.hashValue[cacheKey] = value } async hash( - { preserveOrder = false, hashFn = 'sha256' }: IHashSettings = { + { preserveOrder = false, hashFn = 'sha256', syncTags = false }: IHashSettings = { preserveOrder: false, hashFn: 'sha256', } ): Promise { + const cacheKey = hashCacheKey({ preserveOrder, hashFn, syncTags }) if (!this.hashValue) { this.hashValue = {} } - if (typeof this.hashValue[hashFn] === 'undefined' || this.hashValue[hashFn] === null) { - const json = JSON.stringify({ title: this.title, url: this.url }) + if (typeof this.hashValue[cacheKey] === 'undefined' || this.hashValue[cacheKey] === null) { + // Nextcloud Bookmarks hashes the very same JSON server-side, with the + // fields in exactly this order (`fields[]=title&fields[]=url&fields[]=tags`) + // and the tags sorted the same way (see normalizeTags), so don't reorder + // or add keys here lightly. + const json = syncTags + ? JSON.stringify({ title: this.title, url: this.url, tags: this.tags || [] }) + : JSON.stringify({ title: this.title, url: this.url }) if (hashFn === 'sha256') { - this.hashValue[hashFn] = await Crypto.sha256(json) + this.hashValue[cacheKey] = await Crypto.sha256(json) } else if (hashFn === 'xxhash3') { - this.hashValue[hashFn] = await Crypto.xxhash32(json) + this.hashValue[cacheKey] = await Crypto.xxhash32(json) } else if (hashFn === 'murmur3') { - this.hashValue[hashFn] = await Crypto.murmurHash3(json) + this.hashValue[cacheKey] = await Crypto.murmurHash3(json) } else { throw new Error('Unsupported hash function specified') } } - return this.hashValue[hashFn] + return this.hashValue[cacheKey] } clone(withHash?: boolean): Bookmark { @@ -433,18 +507,18 @@ export class Folder { } setHashCacheValue(hashSettings: IHashSettings, value: string): void { - const cacheKey = `${hashSettings.preserveOrder}-${hashSettings.hashFn}` + const cacheKey = hashCacheKey(hashSettings) if (!this.hashValue) this.hashValue = {} this.hashValue[cacheKey] = value } async hash( - { preserveOrder = false, hashFn = 'sha256' }: IHashSettings = { + { preserveOrder = false, hashFn = 'sha256', syncTags = false }: IHashSettings = { preserveOrder: false, hashFn: 'sha256', } ): Promise { - const cacheKey = `${preserveOrder}-${hashFn}` + const cacheKey = hashCacheKey({ preserveOrder, hashFn, syncTags }) if (this.hashValue && typeof this.hashValue[cacheKey] !== 'undefined') { return this.hashValue[cacheKey] } @@ -475,7 +549,7 @@ export class Folder { title: this.title, children: await Parallel.map( children, - (child) => child.hash({ preserveOrder, hashFn }), + (child) => child.hash({ preserveOrder, hashFn, syncTags }), 1 ), }) diff --git a/src/lib/adapters/Caching.ts b/src/lib/adapters/Caching.ts index 987620d7d6..23f8587aae 100644 --- a/src/lib/adapters/Caching.ts +++ b/src/lib/adapters/Caching.ts @@ -93,6 +93,11 @@ export default class CachingAdapter implements Adapter, BulkImportResource = null): Promise { + if (!tags.length) { + return + } + await this.sendRequest( + 'POST', + `/api/v1/bookmarks/${id}/tags`, + 'application/json', + { tags: tags.map((tagName) => ({ tagName })) }, + false, + item + ) + } + + async detachTags(id: string | number, tags: string[], item: TItem = null): Promise { + if (!tags.length) { + return + } + await this.sendRequest( + 'DELETE', + `/api/v1/bookmarks/${id}/tags`, + 'application/json', + { tags: tags.map((tagName) => ({ tagName })) }, + false, + item + ) + } + + /** + * Karakeep has no "replace all tags" call, so reconcile against what's there. + */ + async setBookmarkTags(id: string | number, tags: string[], item: TItem = null): Promise { + const response = await this.sendRequest( + 'GET', + `/api/v1/bookmarks/${id}?includeContent=false` + ) + const currentTags = (response.tags || []).map((tag) => tag.name) + await this.attachTags(id, tags.filter((tag) => !currentTags.includes(tag)), item) + await this.detachTags(id, currentTags.filter((tag) => !tags.includes(tag)), item) + } + async updateBookmark(bookmark: Bookmark): Promise { Logger.log('(karakeep)UPDATE', { bookmark }) const [id, oldParentId] = this.parseBookmarkId(bookmark.id) @@ -170,6 +218,10 @@ export default class KarakeepAdapter implements Adapter, IResource tag.name), location: ItemLocation.SERVER, }) ) @@ -617,6 +670,7 @@ export default class KarakeepAdapter implements Adapter, IResource ({ name })), collection: { id: bookmark.parentId, }, @@ -135,7 +136,10 @@ export default class LinkwardenAdapter implements Adapter, IResource ({ name })), collection: { id: bookmark.parentId, name: collection.name, @@ -147,6 +151,16 @@ export default class LinkwardenAdapter implements Adapter, IResource { + try { + const { response: link } = await this.sendRequest('GET', `/api/v1/links/${id}`) + return (link?.tags || []).map(tag => tag.name) + } catch (e) { + Logger.log('(linkwarden) Failed to read existing tags of link ' + id, e) + return [] + } + } + async removeBookmark(bookmark: Bookmark): Promise { Logger.log('(linkwarden)DELETE', {bookmark}) try { @@ -293,6 +307,7 @@ export default class LinkwardenAdapter implements Adapter, IResource tag.name), location: ItemLocation.SERVER, })) ), @@ -454,6 +469,7 @@ export default class LinkwardenAdapter implements Adapter, IResource = null private capabilities: any private ticket: string private ticketTimestamp: number @@ -228,6 +232,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes id: bm.id as number | string, url: (bm.target || bm.url) as string, title: bm.title as string, + tags: bm.tags as string[], parentId: null, location: ItemLocation.SERVER, } @@ -246,8 +251,28 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes }) } + /** + * Map of upstream bookmark id -> tag names, lazily fetched once per sync. + * Only ever called when tag syncing is negotiated, so accounts that don't use + * tags don't pay for the extra listing. + */ + async getTagsByBookmarkId():Promise> { + if (this.tagsByBookmarkId) { + return this.tagsByBookmarkId + } + const list = await this.getBookmarksList() + const tagsByBookmarkId = {} + for (const bookmark of list) { + // list ids are the plain upstream ids, no ';parentId' suffix + tagsByBookmarkId[String(bookmark.id)] = bookmark.tags || [] + } + this.tagsByBookmarkId = tagsByBookmarkId + return tagsByBookmarkId + } + async getBookmarksTree(loadAll = false):Promise> { this.list = null // clear cache before starting a new sync + this.tagsByBookmarkId = null if (!loadAll) { return this.getSparseBookmarksTree() @@ -332,9 +357,16 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes if (this.capabilities && this.capabilities.bookmarks && this.capabilities.bookmarks['hash-function'] && !this.capabilities.bookmarks['hash-function'].includes[hashFn]) { throw new Error('Selected hash function is not supported by server') } + // The server hashes bookmarks as json_encode of the requested fields, in the + // requested order -- Bookmark#hash builds the very same JSON. Without + // fields[] the server defaults to title+url, which is what we want when tags + // aren't synced. + const fields = this.hashSettings.syncTags + ? '&fields[]=title&fields[]=url&fields[]=tags' + : '' return this.sendRequest( 'GET', - `index.php/apps/bookmarks/public/rest/v2/folder/${folderId}/hash?hashFn=${hashFn}` + `index.php/apps/bookmarks/public/rest/v2/folder/${folderId}/hash?hashFn=${hashFn}${fields}` ) .catch(() => { return { data: '0' } // fallback @@ -349,6 +381,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes 'GET', `index.php/apps/bookmarks/public/rest/v2/folder/${folderId}/children?layers=${layers}` ) + const tagsByBookmarkId = this.hashSettings?.syncTags ? await this.getTagsByBookmarkId() : null const children = childrenJson.data const recurseChildren = (folderId, children) => { return children.map((item) => { @@ -361,6 +394,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes title: item.title, parentId: folderId, url: item.target || item.url, + ...(tagsByBookmarkId && { tags: tagsByBookmarkId[String(item.id)] || [] }), location: ItemLocation.SERVER, }) } else if (item.type === 'folder') { @@ -685,6 +719,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes url: bm.url, title: bm.title, folders: [bm.parentId], + ...(this.hashSettings?.syncTags && { tags: bm.tags || [] }), } let json @@ -749,7 +784,9 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes (!this.tree.findFolder(parentId) || this.tree.findFolder(parentId).findItemFilter('bookmark', i => i.canMergeWith(newBm)) || !this.tree.findFolder(parentId).loaded) ) .concat([newBm.parentId]), - tags: bms[0].tags, + // When we don't sync tags we still have to send the ones the server + // already has, since the endpoint replaces the whole tag list. + tags: this.hashSettings?.syncTags ? (newBm.tags || []) : bms[0].tags, } try { @@ -1118,6 +1155,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return { preserveOrder: true, hashFn, + supportsTags: true, } } diff --git a/src/lib/browser/BrowserTree.ts b/src/lib/browser/BrowserTree.ts index 95293c6eb8..301e8b1e56 100644 --- a/src/lib/browser/BrowserTree.ts +++ b/src/lib/browser/BrowserTree.ts @@ -401,7 +401,9 @@ export default class BrowserTree implements IResource async getCapabilities(): Promise { return { preserveOrder: true, - hashFn: ['xxhash3', 'murmur3', 'sha256'] + hashFn: ['xxhash3', 'murmur3', 'sha256'], + // The browsers' bookmark APIs have no notion of tags + supportsTags: false, } } diff --git a/src/lib/interfaces/Resource.ts b/src/lib/interfaces/Resource.ts index 98011df1eb..96506a5ead 100644 --- a/src/lib/interfaces/Resource.ts +++ b/src/lib/interfaces/Resource.ts @@ -6,11 +6,23 @@ export type THashFunction = 'sha256' | 'murmur3' | 'xxhash3' export interface ICapabilities { preserveOrder: boolean, hashFn: THashFunction[], + /** + * Whether this resource can store and return a bookmark's `tags`. + * Tags are only synced if *both* resources of a sync say yes -- otherwise + * the side that does support them keeps its tags untouched. + */ + supportsTags: boolean, } export interface IHashSettings { preserveOrder: boolean, hashFn: THashFunction, + /** + * Negotiated from both resources' `supportsTags`. When true, tags take part + * in hashing (so that tag-only changes propagate up folder hashes) and are + * compared and written by the sync. + */ + syncTags?: boolean, } export interface IResource { diff --git a/src/lib/native/NativeTree.ts b/src/lib/native/NativeTree.ts index b504cc7e67..134cbd9b55 100644 --- a/src/lib/native/NativeTree.ts +++ b/src/lib/native/NativeTree.ts @@ -3,7 +3,7 @@ import { Bookmark, Folder, ItemLocation } from '../Tree' import Ordering from '../interfaces/Ordering' import CachingAdapter from '../adapters/Caching' import IAccountStorage from '../interfaces/AccountStorage' -import { BulkImportResource, IHashSettings } from '../interfaces/Resource' +import { BulkImportResource, ICapabilities, IHashSettings } from '../interfaces/Resource' import { isTest } from '../isTest' export default class NativeTree extends CachingAdapter implements BulkImportResource { @@ -169,4 +169,12 @@ export default class NativeTree extends CachingAdapter implements BulkImportReso isAtomic(): boolean { return false } + + async getCapabilities(): Promise { + return { + ...(await super.getCapabilities()), + // Our own tree stores whatever we put into it, tags included + supportsTags: true, + } + } } diff --git a/src/lib/serializers/Html.ts b/src/lib/serializers/Html.ts index 9c0a839318..37c2fd5cc4 100644 --- a/src/lib/serializers/Html.ts +++ b/src/lib/serializers/Html.ts @@ -16,7 +16,9 @@ class HtmlSerializer implements Serializer { .map(child => { if (child instanceof Bookmark) { return ( - `${indent}
${this._htmlentities_encode(child.title)}\n` + // Netscape's TAGS attribute is comma separated; Nextcloud Bookmarks + // picks it up on import, so bulk imports don't lose their tags. + `${indent}
${this._htmlentities_encode(child.title)}\n` ) } else if (child instanceof Folder) { const nextIndent = indent + ' ' diff --git a/src/lib/strategies/Default.ts b/src/lib/strategies/Default.ts index 1366b4f652..521d46750f 100644 --- a/src/lib/strategies/Default.ts +++ b/src/lib/strategies/Default.ts @@ -598,6 +598,9 @@ export default class SyncProcess { preserveOrder: localCapabilities.preserveOrder && serverCapabilities.preserveOrder, // Find the first hFn that localTree supports as well, ie order matters hashFn: serverCapabilities.hashFn.find(hashFn => localCapabilities.hashFn.includes(hashFn)), + // Only sync tags if both ends can actually store them. If one side can't, + // the other side's tags are left alone rather than being wiped. + syncTags: Boolean(localCapabilities.supportsTags && serverCapabilities.supportsTags), } Logger.log(`using the following HashSettings: ${JSON.stringify(this.hashSettings)}`) this.localTree.setHashSettings(this.hashSettings) diff --git a/src/test/node-suite.js b/src/test/node-suite.js index 5945eeac99..645bc1fa71 100644 --- a/src/test/node-suite.js +++ b/src/test/node-suite.js @@ -5,6 +5,7 @@ export async function registerNodeSuite() { await import('./sync_basic_slave.test.js') await import('./sync_basic_overwrite.test.js') await import('./sync_advanced.test.js') + await import('./sync_tags.test.js') if (process.env.FLOCCUS_NODE_INCLUDE_BENCHMARK === 'true') { await import('./benchmark.test.js') diff --git a/src/test/sync_tags.test.js b/src/test/sync_tags.test.js new file mode 100644 index 0000000000..790cffb641 --- /dev/null +++ b/src/test/sync_tags.test.js @@ -0,0 +1,383 @@ +import Account from '../lib/Account' +import { Bookmark, Folder, ItemLocation, ItemType } from '../lib/Tree' +import * as AsyncParallel from 'async-parallel' +import Controller from '../lib/Controller' +import { + clearLocalResource, + DUMP_LOGS, + expect, + expectTreeEqual, + getAllBookmarks, + getEnv, + seedTestRandom, + stringifyAccountData, + withSyncConnection +} from './utils' + +describe('Floccus', function() { + this.timeout(120000) // no test should run longer than 120s + this.slow(20000) // 20s is slow + + const { SEED, ACCOUNTS } = getEnv() + beforeEach(function() { + seedTestRandom(SEED) + }) + + before(async function() { + const controller = await Controller.getSingleton() + controller.setEnabled(false) + }) + after(async function() { + const controller = await Controller.getSingleton() + controller.setEnabled(true) + }) + + ACCOUNTS.forEach(ACCOUNT_DATA => { + // The ' test ' in the title is load-bearing: CI greps for ' test' + // (see .github/workflows/android-appium.yml), so a suite without it is + // silently filtered out of the Android and Selenium runs. + describe(`${stringifyAccountData(ACCOUNT_DATA)} test ${ACCOUNT_DATA.serverRoot ? 'subfolder' : 'root'} Tag Sync`, function() { + let account + let tagsSupported + let bookmarkUrl + + beforeEach('set up account', async function() { + // Nextcloud keys bookmarks by URL per user, and deleting one only + // soft-deletes its tree entry -- the bookmark row and its tags stay in + // the trash. Re-creating the same URL calls softUndeleteEntry() and + // merges the tags in with tagMapper->addTo() (create appends where + // update replaces), so a shared URL would carry tags from one test into + // the next. Both Nextcloud profiles stringify the same, hence the + // root/subfolder part. + const scope = `${stringifyAccountData(ACCOUNT_DATA)}-${ACCOUNT_DATA.serverRoot ? 'subfolder' : 'root'}` + const slug = `${scope}-${this.currentTest.title}`.replace(/[^a-z0-9]+/gi, '-').toLowerCase() + bookmarkUrl = `http://ur.l/${slug}/` + + account = await Account.create(ACCOUNT_DATA) + if (ACCOUNT_DATA.type === 'fake') { + account.server.bookmarksCache = new Folder({ + id: '', + title: 'root', + location: ItemLocation.SERVER + }) + } + await account.init() + + // Tags only travel if both ends of this account can hold them. The + // browser's bookmark API can't, so in the extension this whole suite is + // moot -- skip rather than assert the wrong thing. + const localCapabilities = await (await account.getResource()).getCapabilities() + const serverCapabilities = await account.server.getCapabilities() + tagsSupported = Boolean(localCapabilities.supportsTags && serverCapabilities.supportsTags) + if (!tagsSupported) { + this.skip() + } + }) + afterEach('clean up account', async function() { + DUMP_LOGS(this.currentTest) + if (!account) return + await clearLocalResource(account) + // A skipped suite never got as far as touching the server, so don't + // spend a round trip per skipped test tearing down nothing + if (tagsSupported && ACCOUNT_DATA.type !== 'fake') { + await account.setData({ serverRoot: null }) + account.lockTimeout = 0 + const tree = await getAllBookmarks(account) + await withSyncConnection(account, async() => { + await AsyncParallel.each(tree.children, async(child) => { + if (child instanceof Folder) { + await account.server.removeFolder(child) + } else { + await account.server.removeBookmark(child) + } + }) + }) + } + await account.delete() + }) + + /** + * Create `foo/` with one tagged bookmark in it and sync it up. + */ + const setUpTaggedBookmark = async(tags) => { + const localResource = await account.getResource() + const localRoot = (await localResource.getBookmarksTree(true)).id + const fooFolder = await localResource.createFolder(new Folder({ + title: 'foo', + parentId: localRoot, + location: ItemLocation.LOCAL, + })) + const bookmarkId = await localResource.createBookmark(new Bookmark({ + title: 'url', + url: bookmarkUrl, + tags, + parentId: fooFolder, + location: ItemLocation.LOCAL, + })) + await account.sync() + expect(account.getData().error).to.not.be.ok + return { localResource, localRoot, fooFolder, bookmarkId } + } + + const findServerBookmark = async(url) => { + const tree = await getAllBookmarks(account) + return tree.findItemFilter(ItemType.BOOKMARK, (item) => item.url === url) + } + + const findLocalBookmark = async(url) => { + const localResource = await account.getResource() + const tree = await localResource.getBookmarksTree(true) + return tree.findItemFilter(ItemType.BOOKMARK, (item) => item.url === url) + } + + it('should upload tags of a new local bookmark', async function() { + await setUpTaggedBookmark(['foo', 'bar']) + + const serverBookmark = await findServerBookmark(bookmarkUrl) + expect(serverBookmark).to.be.ok + expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + }) + + it('should download tags of a new server bookmark', async function() { + const localResource = await account.getResource() + const localRoot = (await localResource.getBookmarksTree(true)).id + await localResource.createFolder(new Folder({ + title: 'foo', + parentId: localRoot, + location: ItemLocation.LOCAL, + })) + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverTree = await getAllBookmarks(account) + const serverFolder = serverTree.findItemFilter(ItemType.FOLDER, (item) => item.title === 'foo') + await withSyncConnection(account, async() => { + await account.server.createBookmark(new Bookmark({ + title: 'url', + url: bookmarkUrl, + tags: ['from-server'], + parentId: serverFolder.id, + location: ItemLocation.SERVER, + })) + }) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const localBookmark = await findLocalBookmark(bookmarkUrl) + expect(localBookmark).to.be.ok + expect(localBookmark.tags).to.deep.equal(['from-server']) + }) + + it('should propagate a tag added locally', async function() { + const { localResource, bookmarkId } = await setUpTaggedBookmark(['foo']) + + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'url', + url: bookmarkUrl, + tags: ['foo', 'added'], + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark(bookmarkUrl) + expect([...serverBookmark.tags].sort()).to.deep.equal(['added', 'foo']) + }) + + it('should propagate a tag removed locally', async function() { + const { localResource, bookmarkId } = await setUpTaggedBookmark(['foo', 'bar']) + + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'url', + url: bookmarkUrl, + tags: ['foo'], + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark(bookmarkUrl) + expect(serverBookmark.tags).to.deep.equal(['foo']) + }) + + it('should propagate a tag changed on the server', async function() { + await setUpTaggedBookmark(['foo']) + + const serverBookmark = await findServerBookmark(bookmarkUrl) + await withSyncConnection(account, async() => { + await account.server.updateBookmark(new Bookmark({ + id: serverBookmark.id, + title: serverBookmark.title, + url: serverBookmark.url, + tags: ['foo', 'server-side'], + parentId: serverBookmark.parentId, + location: ItemLocation.SERVER, + })) + }) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const localBookmark = await findLocalBookmark(bookmarkUrl) + expect([...localBookmark.tags].sort()).to.deep.equal(['foo', 'server-side']) + }) + + it('should not resurrect tags removed on the server', async function() { + await setUpTaggedBookmark(['foo', 'bar']) + + const serverBookmark = await findServerBookmark(bookmarkUrl) + await withSyncConnection(account, async() => { + await account.server.updateBookmark(new Bookmark({ + id: serverBookmark.id, + title: serverBookmark.title, + url: serverBookmark.url, + tags: [], + parentId: serverBookmark.parentId, + location: ItemLocation.SERVER, + })) + }) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const localBookmark = await findLocalBookmark(bookmarkUrl) + expect(localBookmark.tags || []).to.deep.equal([]) + }) + + it('should leave tags alone when only the title changes', async function() { + const { localResource, bookmarkId } = await setUpTaggedBookmark(['foo', 'bar']) + + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'a new title', + url: bookmarkUrl, + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark(bookmarkUrl) + expect(serverBookmark.title).to.equal('a new title') + expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + }) + + it('should keep tags across a move', async function() { + const { localResource, localRoot, bookmarkId } = await setUpTaggedBookmark(['foo']) + const otherFolder = await localResource.createFolder(new Folder({ + title: 'other', + parentId: localRoot, + location: ItemLocation.LOCAL, + })) + + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'url', + url: bookmarkUrl, + tags: ['foo'], + parentId: otherFolder, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const tree = await getAllBookmarks(account) + expectTreeEqual( + tree, + new Folder({ + title: tree.title, + children: [ + new Folder({ title: 'foo', children: [] }), + new Folder({ + title: 'other', + children: [ + new Bookmark({ title: 'url', url: bookmarkUrl, tags: ['foo'] }) + ] + }), + ] + }), + true, + false, + true + ) + }) + + it('should converge after a second sync without further changes', async function() { + await setUpTaggedBookmark(['foo', 'bar']) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark(bookmarkUrl) + const localBookmark = await findLocalBookmark(bookmarkUrl) + expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + expect([...localBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + }) + + it('should not treat a reordering of tags as a change', async function() { + const { localResource, bookmarkId } = await setUpTaggedBookmark(['aaa', 'zzz']) + + // normalizeTags sorts, so writing the same tags in a different order + // is not a change at all -- nothing should reach the server + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'url', + url: bookmarkUrl, + tags: ['zzz', 'aaa'], + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + expect((await findServerBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) + expect((await findLocalBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) + }) + + it('should ignore the order a server returns tags in', async function() { + if (ACCOUNT_DATA.type !== 'fake') { + // Forcing a specific server-side tag order means reaching into the + // server's tree, which only the fake one lets us do + return this.skip() + } + await setUpTaggedBookmark(['aaa', 'zzz']) + + // Linkwarden hands a bookmark's tags back ordered by tag identity, not + // in the order they were written (its query specifies no ordering), so + // our write never comes back the way we sent it. Make the fake server + // behave the same way. + const serverBookmark = account.server.bookmarksCache.findItemFilter( + ItemType.BOOKMARK, + (item) => item.url === bookmarkUrl + ) + serverBookmark.tags = ['zzz', 'aaa'] + + const serverUpdates = [] + const updateBookmark = account.server.updateBookmark.bind(account.server) + account.server.updateBookmark = (bookmark) => { + serverUpdates.push(bookmark.id) + return updateBookmark(bookmark) + } + + // Sorting on the way in means the server's order never reaches the + // diff: no write is provoked and what we hold doesn't budge, however + // often we sync + await account.sync() + await account.sync() + expect(account.getData().error).to.not.be.ok + expect((await findLocalBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) + expect(serverUpdates).to.deep.equal([]) + }) + }) + }) +}) diff --git a/src/test/test.js b/src/test/test.js index bc26430206..7cec60e486 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -4,6 +4,7 @@ import './sync_basic.test.js' import './sync_basic_slave.test.js' import './sync_basic_overwrite.test.js' import './sync_advanced.test.js' +import './sync_tags.test.js' import './sync_tabs.test.js' import './sync_tabgroups.test.js' import './benchmark.test.js' \ No newline at end of file diff --git a/src/test/utils.js b/src/test/utils.js index 96115d94f6..03d6bbfbe2 100644 --- a/src/test/utils.js +++ b/src/test/utils.js @@ -231,9 +231,10 @@ export const expectTreeEqual = function( tree1, tree2, ignoreEmptyFolders, - checkOrder = true + checkOrder = true, + checkTags = false ) { - expectTreeEqualRec(tree1, tree2, 0, ignoreEmptyFolders, checkOrder) + expectTreeEqualRec(tree1, tree2, 0, ignoreEmptyFolders, checkOrder, checkTags) } let expectTreeEqualRec = function( @@ -241,12 +242,19 @@ let expectTreeEqualRec = function( tree2, recDepth, ignoreEmptyFolders, - checkOrder + checkOrder, + checkTags ) { try { expect(tree1.title).to.equal(tree2.title) if (tree2.url) { expect(tree1.url).to.equal(tree2.url) + if (checkTags) { + // Tags are a set, their order carries no meaning + expect([...(tree1.tags || [])].sort()).to.deep.equal( + [...(tree2.tags || [])].sort() + ) + } } else { if (checkOrder === false) { tree2.children.sort((a, b) => { @@ -277,7 +285,8 @@ let expectTreeEqualRec = function( child2, recDepth + 1, ignoreEmptyFolders, - checkOrder + checkOrder, + checkTags ) }) } @@ -312,6 +321,10 @@ export async function withSyncConnection(account, fn) { adapter.setHashSettings({ preserveOrder: capabilities.preserveOrder, hashFn: capabilities.hashFn[0], + // The local side of a native sync always supports tags, so this mirrors + // what a real sync would negotiate -- and makes the fetched tree carry + // tags for the assertions. + syncTags: capabilities.supportsTags, }) await fn() if (adapter.onSyncComplete) await adapter.onSyncComplete() diff --git a/src/ui/components/native/DialogEditBookmark.vue b/src/ui/components/native/DialogEditBookmark.vue index 98c2107b04..ab5646e5a4 100644 --- a/src/ui/components/native/DialogEditBookmark.vue +++ b/src/ui/components/native/DialogEditBookmark.vue @@ -19,6 +19,19 @@ :error="Boolean(urlError)" :error-messages="urlError" :label="t('LabelLink')" /> + [], } }, data() { @@ -89,6 +110,7 @@ export default { temporaryUrl: '', urlError: null, temporaryParent: null, + temporaryTags: [], displayFolderChooser: false, } }, @@ -120,6 +142,7 @@ export default { mounted() { this.temporaryTitle = this.bookmark.title || '' this.temporaryUrl = this.bookmark.url || '' + this.temporaryTags = [...(this.bookmark.tags || [])] const parentFolder = this.tree.findFolder(this.bookmark.parentId) || this.tree.findFolder(this.parentFolder) || this.tree.findFolder(this.$store.state.lastFolders[this.$route.params.accountId]) || @@ -134,7 +157,18 @@ export default { if (!this.tree.findFolder(this.temporaryParent)) { return } - this.$emit('save', {title: this.temporaryTitle, url: this.temporaryUrl, parentId: this.temporaryParent}) + this.$emit('save', { + title: this.temporaryTitle, + url: this.temporaryUrl, + parentId: this.temporaryParent, + // Leave tags alone entirely if this account can't sync them, so we + // don't clear tags that some other client set. + ...(this.supportsTags && { + tags: this.temporaryTags + .map((tag) => String(tag).trim()) + .filter(Boolean), + }), + }) this.$emit('update:display', false) }, onTriggerFolderChooser() { diff --git a/src/ui/components/native/Item.vue b/src/ui/components/native/Item.vue index 185e054798..778e2b6ba3 100644 --- a/src/ui/components/native/Item.vue +++ b/src/ui/components/native/Item.vue @@ -29,6 +29,20 @@ :items="item.type === 'bookmark' ? getBookmarkPath(item) : getFolderPath(item)" :tree="tree" /> + + + {{ tag }} + + @@ -130,6 +144,12 @@ export default { } return this.$store.state.accounts[this.accountId].data.allowNetwork }, + tags() { + if (!this.item || this.item.type !== 'bookmark') { + return [] + } + return this.item.tags || [] + }, }, methods: { getFolderPath(item) { @@ -166,5 +186,7 @@ export default { \ No newline at end of file diff --git a/src/ui/store/definitions.js b/src/ui/store/definitions.js index 3ee65dc737..496504d7ce 100644 --- a/src/ui/store/definitions.js +++ b/src/ui/store/definitions.js @@ -7,6 +7,7 @@ export const actions = { SELECT_ACCOUNT: 'SELECT_ACCOUNT', LOAD_TREE: 'LOAD_TREE', LOAD_TREE_FROM_DISK: 'LOAD_TREE_FROM_DISK', + LOAD_TAG_SUPPORT: 'LOAD_TAG_SUPPORT', CREATE_BOOKMARK: 'CREATE_BOOKMARK', EDIT_BOOKMARK: 'EDIT_BOOKMARK', DELETE_BOOKMARK: 'DELETE_BOOKMARK', @@ -51,6 +52,7 @@ export const mutations = { STORE_ACCOUNT_DATA: 'STORE_ACCOUNT_DATA', REMOVE_ACCOUNT: 'REMOVE_ACCOUNT', LOAD_TREE: 'LOAD_TREE', + SET_TAG_SUPPORT: 'SET_TAG_SUPPORT', SET_LOGIN_FLOW_STATE: 'SET_LOGIN_FLOW_STATE', SET_LAST_FOLDER: 'SET_LAST_FOLDER', SET_LAST_ACCOUNT: 'SET_LAST_ACCOUNT' diff --git a/src/ui/store/native/actions.js b/src/ui/store/native/actions.js index 3ef3b2414f..f00e659302 100644 --- a/src/ui/store/native/actions.js +++ b/src/ui/store/native/actions.js @@ -39,8 +39,23 @@ export const actionsDefinition = { const tree = await account.getResource() const rootFolder = await tree.getBookmarksTree(true) await commit(mutations.LOAD_TREE, rootFolder) + await dispatch(actions.LOAD_TAG_SUPPORT, id) await dispatch(actions.SET_LAST_ACCOUNT, id) }, + async [actions.LOAD_TAG_SUPPORT]({ commit }, id) { + const account = await Account.get(id) + const [localTree, server] = [await account.getResource(), await account.getServer()] + // Both getCapabilities() implementations answer from local state, so this + // doesn't hit the network. + const [localCapabilities, serverCapabilities] = await Promise.all([ + localTree.getCapabilities(), + server.getCapabilities(), + ]) + await commit(mutations.SET_TAG_SUPPORT, { + accountId: id, + supportsTags: Boolean(localCapabilities.supportsTags && serverCapabilities.supportsTags), + }) + }, async [actions.LOAD_TREE_FROM_DISK]({ commit, dispatch, state }, id) { const account = await Account.get(id) if (account.syncing) { diff --git a/src/ui/store/native/index.js b/src/ui/store/native/index.js index 0526307b83..c93ac85532 100644 --- a/src/ui/store/native/index.js +++ b/src/ui/store/native/index.js @@ -12,6 +12,8 @@ export default new Store({ locked: false, accounts: {}, tree: null, + // accountId -> whether both ends of that account's sync can store tags + tagSupport: {}, loginFlow: { isRunning: false }, diff --git a/src/ui/store/native/mutations.js b/src/ui/store/native/mutations.js index 5d0bd72440..e33a334cfc 100644 --- a/src/ui/store/native/mutations.js +++ b/src/ui/store/native/mutations.js @@ -17,6 +17,9 @@ export const mutationsDefinition = { [mutations.LOAD_TREE](state, tree) { state.tree = tree }, + [mutations.SET_TAG_SUPPORT](state, {accountId, supportsTags}) { + Vue.set(state.tagSupport, accountId, supportsTags) + }, [mutations.SET_LOGIN_FLOW_STATE](state, running) { Vue.set(state.loginFlow, 'isRunning', running) }, diff --git a/src/ui/views/native/AddBookmarkIntent.vue b/src/ui/views/native/AddBookmarkIntent.vue index 83f935003b..ad625a8aba 100644 --- a/src/ui/views/native/AddBookmarkIntent.vue +++ b/src/ui/views/native/AddBookmarkIntent.vue @@ -64,6 +64,19 @@ :error="Boolean(urlError)" :error-messages="urlError" :label="t('LabelLink')" /> + bm.url === this.url) - } + }, + supportsTags() { + return Boolean(this.$store.state.tagSupport[this.id]) + }, + allTags() { + if (!this.tree || !this.tree.index) { + return [] + } + const tags = new Set() + for (const key in this.tree.index.bookmark) { + for (const tag of this.tree.index.bookmark[key].tags || []) { + tags.add(tag) + } + } + return [...tags].sort((tag1, tag2) => tag1.localeCompare(tag2)) + }, }, watch: { loading() { @@ -176,6 +205,8 @@ export default { if (this.tree) { const parentFolder = this.tree.findFolder(this.$store.state.lastFolders[this.id]) || this.tree.findFolder(this.tree.id) this.temporaryParent = parentFolder.id + // The tree may still be the previous account's, so ask about this one + this.$store.dispatch(actions.LOAD_TAG_SUPPORT, this.id) } else { this.$store.dispatch(actions.LOAD_TREE, this.id) } @@ -187,7 +218,15 @@ export default { } await this.$store.dispatch(actions.CREATE_BOOKMARK, { accountId: this.id, - bookmark: new Bookmark({ id: null, parentId: this.temporaryParent, title: this.title, url: this.url }) + bookmark: new Bookmark({ + id: null, + parentId: this.temporaryParent, + title: this.title, + url: this.url, + ...(this.supportsTags && { + tags: this.tags.map((tag) => String(tag).trim()).filter(Boolean), + }), + }) }) SendIntent.finish() await this.$router.push({name: routes.TREE, params: {accountId: this.id}}) diff --git a/src/ui/views/native/Tree.vue b/src/ui/views/native/Tree.vue index b437055716..849ee65011 100644 --- a/src/ui/views/native/Tree.vue +++ b/src/ui/views/native/Tree.vue @@ -186,6 +186,28 @@ {{ currentAccount ? currentAccount.label : '' }} + @@ -283,6 +306,7 @@ @click="clickItem(item)" @share="shareBookmark(item)" @edit="editItem(item)" + @tag="searchByTag($event)" @delete="deleteItem(item)" /> @@ -353,6 +377,8 @@ :display.sync="isAddingBookmark" :tree="tree" :parent-folder="currentFolderId" + :supports-tags="supportsTags" + :tag-suggestions="allTags" @save="createBookmark($event)" /> count2 - count1 || tag1.localeCompare(tag2)) + .map(([tag]) => tag) + }, + activeTag() { + const query = (this.searchQuery || '').trim() + return query.startsWith('#') ? query.slice(1).trim() : null + }, + allTags() { + if (!this.tree || !this.tree.index) { + return [] + } + const tags = new Set() + for (const key in this.tree.index.bookmark) { + for (const tag of this.tree.index.bookmark[key].tags || []) { + tags.add(tag) + } + } + return [...tags].sort((tag1, tag2) => tag1.localeCompare(tag2)) + }, }, watch: { async $route() { @@ -545,28 +612,17 @@ export default { sortBy: current, }) }, - async searchQuery(searchQuery) { - if (searchQuery.trim().length < 3) { - this.searchItems = [] - this.otherSearchItems = [] - return + async searchQuery() { + await this.runSearch() + }, + async tree() { + // Search results hold items of the tree they were collected from, so a + // replaced tree (after an edit or a sync) leaves them stale -- and since + // `items` renders them verbatim while searching, the list would keep + // showing pre-edit titles and tags. Collect them again. + if (this.searchQuery) { + await this.runSearch() } - this.searching = true - await yieldToEventLoop() - this.searchItems = [] - this.otherSearchItems = [] - await this.search( - this.searchItems, - searchQuery.toLowerCase().trim(), - this.currentFolder - ) - await this.search( - this.otherSearchItems, - searchQuery.toLowerCase().trim(), - this.tree, - (item) => !this.searchItems.includes(item) - ) - this.searching = false }, showSearch(showSearch, previous) { if (previous && !showSearch) { @@ -632,9 +688,65 @@ export default { this.searchQuery = query }, 500) }, + searchByTag(tag) { + clearTimeout(this.searchDebounceTimer) + this.searchQuery = '#' + tag + }, + toggleTagSearch(tag) { + if (this.activeTag === tag) { + clearTimeout(this.searchDebounceTimer) + this.searchQuery = '' + return + } + this.searchByTag(tag) + }, + async runSearch() { + const query = (this.searchQuery || '').trim() + // '#tag' searches only need a tag to go on, not three characters + if (query.startsWith('#') ? query.length < 2 : query.length < 3) { + this.searchRun++ + this.searchItems = [] + this.otherSearchItems = [] + this.searching = false + return + } + // The tree can be replaced while we're still collecting (a sync + // finishing, say). Each run owns its own arrays, so a superseded one + // keeps filling arrays nobody renders any more instead of interleaving + // its results into the current ones. + const run = ++this.searchRun + const searchItems = [] + const otherSearchItems = [] + this.searchItems = searchItems + this.otherSearchItems = otherSearchItems + this.searching = true + await yieldToEventLoop() + if (run !== this.searchRun) { + return + } + // Results are pushed into the arrays above as they are found, so they + // show up progressively rather than all at once at the end + await this.search(searchItems, query.toLowerCase(), this.currentFolder) + await this.search( + otherSearchItems, + query.toLowerCase(), + this.tree, + (item) => !searchItems.includes(item) + ) + if (run !== this.searchRun) { + return + } + this.searching = false + }, async search(results, query, tree, filterFunction = (item) => true) { // Refactored to use for loops instead of Object.values/filter let iterations = 0 + // A '#…' query looks for tags only -- folders can't carry tags, so they + // are out of the running entirely. + const tagQuery = query.startsWith('#') ? query.slice(1).trim() : null + if (tagQuery) { + return this.searchByTagQuery(results, tagQuery, tree, filterFunction) + } const folderResults = results for (const key in tree.index.folder) { const item = tree.index.folder[key] @@ -708,6 +820,7 @@ export default { let matchTitleFully = false let matchTitlePartially = false let matchUrl = false + let matchTags = false if (item.title) { matchTitleFully = query.split(' ').every((term) => item.title @@ -724,7 +837,13 @@ export default { .split(' ') .every((term) => item.url.toLowerCase().includes(term)) } - if (matchUrl || matchTitleFully || matchTitlePartially) { + if (item.tags && item.tags.length) { + const tags = item.tags.map((tag) => tag.toLowerCase()) + matchTags = query + .split(' ') + .every((term) => tags.some((tag) => tag.includes(term))) + } + if (matchUrl || matchTitleFully || matchTitlePartially || matchTags) { bookmarkResults.push(item) } } @@ -765,6 +884,27 @@ export default { return results.push.apply(results, bookmarkResults) }, + async searchByTagQuery(results, tagQuery, tree, filterFunction) { + let iterations = 0 + const exactMatches = [] + const partialMatches = [] + for (const key in tree.index.bookmark) { + const item = tree.index.bookmark[key] + if (++iterations % 1000 === 0) { + await yieldToEventLoop() + } + if (!filterFunction(item) || !item.tags || !item.tags.length) { + continue + } + const tags = item.tags.map((tag) => tag.toLowerCase()) + if (tags.includes(tagQuery)) { + exactMatches.push(item) + } else if (tags.some((tag) => tag.includes(tagQuery))) { + partialMatches.push(item) + } + } + return results.push.apply(results, exactMatches.concat(partialMatches)) + }, goBack() { if (this.isAddingBookmark) { this.isAddingBookmark = false @@ -920,4 +1060,22 @@ export default { min-height: 95vh; margin-bottom: 60px; } + +.tag-bar { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + /* momentum scrolling on iOS */ + -webkit-overflow-scrolling: touch; + /* the bar is dragged, not scrollbar-clicked */ + scrollbar-width: none; +} + +.tag-bar::-webkit-scrollbar { + display: none; +} + +.tag-bar__chip { + flex: 0 0 auto; +}