From 4776e6a3d4065c1e5b9b9114f4a46b7a9b635b74 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 5 Sep 2026 15:51:13 +0200 Subject: [PATCH 1/8] [native] feat: Support tags on Android and iOS Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- _locales/en/messages.json | 9 + doc/Adapters.md | 21 +- src/lib/LocalTabs.ts | 1 + src/lib/Scanner.ts | 8 +- src/lib/Tree.ts | 96 ++++- src/lib/adapters/Caching.ts | 9 + src/lib/adapters/Fake.js | 8 + src/lib/adapters/Karakeep.ts | 54 +++ src/lib/adapters/Linkwarden.ts | 18 +- src/lib/adapters/NextcloudBookmarks.ts | 42 ++- src/lib/browser/BrowserTree.ts | 4 +- src/lib/interfaces/Resource.ts | 12 + src/lib/native/NativeTree.ts | 10 +- src/lib/serializers/Html.ts | 4 +- src/lib/strategies/Default.ts | 3 + src/test/node-suite.js | 1 + src/test/sync_tags.test.js | 334 ++++++++++++++++++ src/test/utils.js | 21 +- .../components/native/DialogEditBookmark.vue | 36 +- src/ui/components/native/Item.vue | 24 +- src/ui/store/definitions.js | 2 + src/ui/store/native/actions.js | 15 + src/ui/store/native/index.js | 2 + src/ui/store/native/mutations.js | 3 + src/ui/views/native/AddBookmarkIntent.vue | 43 ++- src/ui/views/native/Tree.vue | 65 +++- 26 files changed, 813 insertions(+), 32 deletions(-) create mode 100644 src/test/sync_tags.test.js 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/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..d74b483d87 100644 --- a/src/lib/Tree.ts +++ b/src/lib/Tree.ts @@ -32,6 +32,70 @@ interface IItemIndex { let HASH_ITERATIONS = 0 +/** + * Bring a bookmark's tags into canonical form: strings only, trimmed, no empty + * entries, no duplicates -- but *order preserving*. + * + * Order is deliberately not normalized: Nextcloud Bookmarks computes its + * server-side folder hashes over `{title, url, tags}` with the tags in database + * order, and we read them from the very same source, so keeping that order lets + * our locally computed hashes agree with the server's. Sorting here would make + * every bookmark tagged outside floccus hash differently from the server's idea + * of it, and that folder would be re-fetched on every single sync. + * + * The flip side is that a pure reordering counts as a change and gets written + * back once -- which is what makes the two sides agree again. + * + * `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 +} + +/** + * 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 +126,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 +165,39 @@ 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`), + // 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 +503,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 +545,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..3a92623824 --- /dev/null +++ b/src/test/sync_tags.test.js @@ -0,0 +1,334 @@ +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 => { + describe(`${stringifyAccountData(ACCOUNT_DATA)} Tag Sync`, function() { + let account + beforeEach('set up account', async function() { + 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() + if (!localCapabilities.supportsTags || !serverCapabilities.supportsTags) { + this.skip() + } + }) + afterEach('clean up account', async function() { + DUMP_LOGS(this.currentTest) + if (!account) return + await clearLocalResource(account) + if (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: 'http://ur.l/', + 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('http://ur.l/') + 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: 'http://ur.l/', + tags: ['from-server'], + parentId: serverFolder.id, + location: ItemLocation.SERVER, + })) + }) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const localBookmark = await findLocalBookmark('http://ur.l/') + 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: 'http://ur.l/', + tags: ['foo', 'added'], + parentId: (await findLocalBookmark('http://ur.l/')).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark('http://ur.l/') + 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: 'http://ur.l/', + tags: ['foo'], + parentId: (await findLocalBookmark('http://ur.l/')).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark('http://ur.l/') + 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('http://ur.l/') + 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('http://ur.l/') + 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('http://ur.l/') + 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('http://ur.l/') + 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: 'http://ur.l/', + parentId: (await findLocalBookmark('http://ur.l/')).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + const serverBookmark = await findServerBookmark('http://ur.l/') + 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: 'http://ur.l/', + 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: 'http://ur.l/', 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('http://ur.l/') + const localBookmark = await findLocalBookmark('http://ur.l/') + expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + expect([...localBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) + }) + + it('should converge when tags are only reordered', async function() { + const { localResource, bookmarkId } = await setUpTaggedBookmark(['foo', 'bar']) + + await localResource.updateBookmark(new Bookmark({ + id: bookmarkId, + title: 'url', + url: 'http://ur.l/', + tags: ['bar', 'foo'], + parentId: (await findLocalBookmark('http://ur.l/')).parentId, + location: ItemLocation.LOCAL, + })) + + await account.sync() + expect(account.getData().error).to.not.be.ok + + // The reorder is written back once so that both sides agree again -- + // no tag may be lost or duplicated in the process + const serverBookmark = await findServerBookmark('http://ur.l/') + expect(serverBookmark.tags).to.deep.equal(['bar', 'foo']) + + // ...and the next sync has nothing left to do + await account.sync() + expect(account.getData().error).to.not.be.ok + expect((await findServerBookmark('http://ur.l/')).tags).to.deep.equal(['bar', 'foo']) + expect((await findLocalBookmark('http://ur.l/')).tags).to.deep.equal(['bar', 'foo']) + }) + }) + }) +}) 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..d0a9ee623a 100644 --- a/src/ui/views/native/Tree.vue +++ b/src/ui/views/native/Tree.vue @@ -235,6 +235,7 @@ @click="clickItem(item)" @share="shareBookmark(item)" @edit="editItem(item)" + @tag="searchByTag($event)" @delete="deleteItem(item)" /> @@ -283,6 +284,7 @@ @click="clickItem(item)" @share="shareBookmark(item)" @edit="editItem(item)" + @tag="searchByTag($event)" @delete="deleteItem(item)" /> @@ -353,6 +355,8 @@ :display.sync="isAddingBookmark" :tree="tree" :parent-folder="currentFolderId" + :supports-tags="supportsTags" + :tag-suggestions="allTags" @save="createBookmark($event)" /> tag1.localeCompare(tag2)) + }, }, watch: { async $route() { @@ -546,7 +567,9 @@ export default { }) }, async searchQuery(searchQuery) { - if (searchQuery.trim().length < 3) { + const trimmed = (searchQuery || '').trim() + // '#tag' searches only need a tag to go on, not three characters + if (trimmed.startsWith('#') ? trimmed.length < 2 : trimmed.length < 3) { this.searchItems = [] this.otherSearchItems = [] return @@ -632,9 +655,19 @@ export default { this.searchQuery = query }, 500) }, + searchByTag(tag) { + clearTimeout(this.searchDebounceTimer) + this.searchQuery = '#' + tag + }, 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 +741,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 +758,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 +805,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 From cbc91c64b13b4103bde2b0a7a8eb1f554ef3542b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 5 Sep 2026 16:14:31 +0200 Subject: [PATCH 2/8] [native] fix: Make sure search items are updated when the tree changes Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- src/ui/views/native/Tree.vue | 72 ++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/src/ui/views/native/Tree.vue b/src/ui/views/native/Tree.vue index d0a9ee623a..421706919b 100644 --- a/src/ui/views/native/Tree.vue +++ b/src/ui/views/native/Tree.vue @@ -437,6 +437,7 @@ export default { otherSearchItems: [], searchItems: [], searching: false, + searchRun: 0, } }, computed: { @@ -566,30 +567,17 @@ export default { sortBy: current, }) }, - async searchQuery(searchQuery) { - const trimmed = (searchQuery || '').trim() - // '#tag' searches only need a tag to go on, not three characters - if (trimmed.startsWith('#') ? trimmed.length < 2 : trimmed.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) { @@ -659,6 +647,44 @@ export default { clearTimeout(this.searchDebounceTimer) this.searchQuery = '#' + 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 From bd0f0485ac0013d6c4dfac6b6743cd768dd41ec9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 5 Sep 2026 16:39:23 +0200 Subject: [PATCH 3/8] [native] feat: Display tags of the current folder in a sheet below the top bar Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- src/ui/views/native/Tree.vue | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/ui/views/native/Tree.vue b/src/ui/views/native/Tree.vue index 421706919b..849ee65011 100644 --- a/src/ui/views/native/Tree.vue +++ b/src/ui/views/native/Tree.vue @@ -186,6 +186,28 @@ {{ currentAccount ? currentAccount.label : '' }} + 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 [] @@ -647,6 +692,14 @@ export default { 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 @@ -1007,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; +} From 313174a98158a6d329f6bccc3cf224993c6f808e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 5 Sep 2026 17:28:55 +0200 Subject: [PATCH 4/8] [native] tests(tags): Add tags tests to Appium test suite Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- src/test/sync_tags.test.js | 13 ++++++++++--- src/test/test.js | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/test/sync_tags.test.js b/src/test/sync_tags.test.js index 3a92623824..4fcc4aaa1b 100644 --- a/src/test/sync_tags.test.js +++ b/src/test/sync_tags.test.js @@ -33,8 +33,12 @@ describe('Floccus', function() { }) ACCOUNTS.forEach(ACCOUNT_DATA => { - describe(`${stringifyAccountData(ACCOUNT_DATA)} Tag Sync`, function() { + // 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 beforeEach('set up account', async function() { account = await Account.create(ACCOUNT_DATA) if (ACCOUNT_DATA.type === 'fake') { @@ -51,7 +55,8 @@ describe('Floccus', function() { // moot -- skip rather than assert the wrong thing. const localCapabilities = await (await account.getResource()).getCapabilities() const serverCapabilities = await account.server.getCapabilities() - if (!localCapabilities.supportsTags || !serverCapabilities.supportsTags) { + tagsSupported = Boolean(localCapabilities.supportsTags && serverCapabilities.supportsTags) + if (!tagsSupported) { this.skip() } }) @@ -59,7 +64,9 @@ describe('Floccus', function() { DUMP_LOGS(this.currentTest) if (!account) return await clearLocalResource(account) - if (ACCOUNT_DATA.type !== 'fake') { + // 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) 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 From 66c67ecdbd19ba568e23f840c2efd5d553ff8314 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 6 Sep 2026 11:06:31 +0200 Subject: [PATCH 5/8] [native] fix(tests): Use different URLs for each test and fix tags convergence Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- src/test/sync_tags.test.js | 111 +++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 28 deletions(-) diff --git a/src/test/sync_tags.test.js b/src/test/sync_tags.test.js index 4fcc4aaa1b..2859859d2a 100644 --- a/src/test/sync_tags.test.js +++ b/src/test/sync_tags.test.js @@ -39,7 +39,20 @@ describe('Floccus', function() { 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({ @@ -96,7 +109,7 @@ describe('Floccus', function() { })) const bookmarkId = await localResource.createBookmark(new Bookmark({ title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags, parentId: fooFolder, location: ItemLocation.LOCAL, @@ -120,7 +133,7 @@ describe('Floccus', function() { it('should upload tags of a new local bookmark', async function() { await setUpTaggedBookmark(['foo', 'bar']) - const serverBookmark = await findServerBookmark('http://ur.l/') + const serverBookmark = await findServerBookmark(bookmarkUrl) expect(serverBookmark).to.be.ok expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) }) @@ -141,7 +154,7 @@ describe('Floccus', function() { await withSyncConnection(account, async() => { await account.server.createBookmark(new Bookmark({ title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags: ['from-server'], parentId: serverFolder.id, location: ItemLocation.SERVER, @@ -151,7 +164,7 @@ describe('Floccus', function() { await account.sync() expect(account.getData().error).to.not.be.ok - const localBookmark = await findLocalBookmark('http://ur.l/') + const localBookmark = await findLocalBookmark(bookmarkUrl) expect(localBookmark).to.be.ok expect(localBookmark.tags).to.deep.equal(['from-server']) }) @@ -162,16 +175,16 @@ describe('Floccus', function() { await localResource.updateBookmark(new Bookmark({ id: bookmarkId, title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags: ['foo', 'added'], - parentId: (await findLocalBookmark('http://ur.l/')).parentId, + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, location: ItemLocation.LOCAL, })) await account.sync() expect(account.getData().error).to.not.be.ok - const serverBookmark = await findServerBookmark('http://ur.l/') + const serverBookmark = await findServerBookmark(bookmarkUrl) expect([...serverBookmark.tags].sort()).to.deep.equal(['added', 'foo']) }) @@ -181,23 +194,23 @@ describe('Floccus', function() { await localResource.updateBookmark(new Bookmark({ id: bookmarkId, title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags: ['foo'], - parentId: (await findLocalBookmark('http://ur.l/')).parentId, + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, location: ItemLocation.LOCAL, })) await account.sync() expect(account.getData().error).to.not.be.ok - const serverBookmark = await findServerBookmark('http://ur.l/') + 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('http://ur.l/') + const serverBookmark = await findServerBookmark(bookmarkUrl) await withSyncConnection(account, async() => { await account.server.updateBookmark(new Bookmark({ id: serverBookmark.id, @@ -212,14 +225,14 @@ describe('Floccus', function() { await account.sync() expect(account.getData().error).to.not.be.ok - const localBookmark = await findLocalBookmark('http://ur.l/') + 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('http://ur.l/') + const serverBookmark = await findServerBookmark(bookmarkUrl) await withSyncConnection(account, async() => { await account.server.updateBookmark(new Bookmark({ id: serverBookmark.id, @@ -234,7 +247,7 @@ describe('Floccus', function() { await account.sync() expect(account.getData().error).to.not.be.ok - const localBookmark = await findLocalBookmark('http://ur.l/') + const localBookmark = await findLocalBookmark(bookmarkUrl) expect(localBookmark.tags || []).to.deep.equal([]) }) @@ -244,15 +257,15 @@ describe('Floccus', function() { await localResource.updateBookmark(new Bookmark({ id: bookmarkId, title: 'a new title', - url: 'http://ur.l/', - parentId: (await findLocalBookmark('http://ur.l/')).parentId, + 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('http://ur.l/') + const serverBookmark = await findServerBookmark(bookmarkUrl) expect(serverBookmark.title).to.equal('a new title') expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) }) @@ -268,7 +281,7 @@ describe('Floccus', function() { await localResource.updateBookmark(new Bookmark({ id: bookmarkId, title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags: ['foo'], parentId: otherFolder, location: ItemLocation.LOCAL, @@ -287,7 +300,7 @@ describe('Floccus', function() { new Folder({ title: 'other', children: [ - new Bookmark({ title: 'url', url: 'http://ur.l/', tags: ['foo'] }) + new Bookmark({ title: 'url', url: bookmarkUrl, tags: ['foo'] }) ] }), ] @@ -304,8 +317,8 @@ describe('Floccus', function() { await account.sync() expect(account.getData().error).to.not.be.ok - const serverBookmark = await findServerBookmark('http://ur.l/') - const localBookmark = await findLocalBookmark('http://ur.l/') + 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']) }) @@ -316,9 +329,9 @@ describe('Floccus', function() { await localResource.updateBookmark(new Bookmark({ id: bookmarkId, title: 'url', - url: 'http://ur.l/', + url: bookmarkUrl, tags: ['bar', 'foo'], - parentId: (await findLocalBookmark('http://ur.l/')).parentId, + parentId: (await findLocalBookmark(bookmarkUrl)).parentId, location: ItemLocation.LOCAL, })) @@ -326,15 +339,57 @@ describe('Floccus', function() { expect(account.getData().error).to.not.be.ok // The reorder is written back once so that both sides agree again -- - // no tag may be lost or duplicated in the process - const serverBookmark = await findServerBookmark('http://ur.l/') - expect(serverBookmark.tags).to.deep.equal(['bar', 'foo']) + // no tag may be lost or duplicated in the process. Compared as a set, + // because the order a server hands its tags back in is its own business + // (Nextcloud's findByBookmark() has no ORDER BY, for one). + const serverBookmark = await findServerBookmark(bookmarkUrl) + expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) // ...and the next sync has nothing left to do await account.sync() expect(account.getData().error).to.not.be.ok - expect((await findServerBookmark('http://ur.l/')).tags).to.deep.equal(['bar', 'foo']) - expect((await findLocalBookmark('http://ur.l/')).tags).to.deep.equal(['bar', 'foo']) + expect([...(await findServerBookmark(bookmarkUrl)).tags].sort()).to.deep.equal(['bar', 'foo']) + expect([...(await findLocalBookmark(bookmarkUrl)).tags].sort()).to.deep.equal(['bar', 'foo']) + }) + + it('should settle when the server returns tags in an order of its own', 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(['foo', 'bar']) + + // 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 = ['bar', 'foo'] + + const serverUpdates = [] + const updateBookmark = account.server.updateBookmark.bind(account.server) + account.server.updateBookmark = (bookmark) => { + serverUpdates.push(bookmark.id) + return updateBookmark(bookmark) + } + + // The differing order registers as a change once, and the server wins + await account.sync() + expect(account.getData().error).to.not.be.ok + const settledTags = [...(await findLocalBookmark(bookmarkUrl)).tags] + expect(settledTags).to.deep.equal(['bar', 'foo']) + const settledUpdates = serverUpdates.length + + // ...and then it stays put, rather than being rewritten every sync + await account.sync() + await account.sync() + expect(account.getData().error).to.not.be.ok + expect([...(await findLocalBookmark(bookmarkUrl)).tags]).to.deep.equal(settledTags) + expect(serverUpdates.length).to.equal(settledUpdates) }) }) }) From 9e2cf20e3c6ccfc93d01d8bbe25f7eb369142554 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sun, 6 Sep 2026 11:14:34 +0200 Subject: [PATCH 6/8] [native] fix(tags): Sort tags in normalizeTags Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- src/lib/Tree.ts | 28 ++++++++++++++----------- src/test/sync_tags.test.js | 43 +++++++++++++------------------------- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/src/lib/Tree.ts b/src/lib/Tree.ts index d74b483d87..109d705bdc 100644 --- a/src/lib/Tree.ts +++ b/src/lib/Tree.ts @@ -34,17 +34,20 @@ let HASH_ITERATIONS = 0 /** * Bring a bookmark's tags into canonical form: strings only, trimmed, no empty - * entries, no duplicates -- but *order preserving*. + * entries, no duplicates, sorted. * - * Order is deliberately not normalized: Nextcloud Bookmarks computes its - * server-side folder hashes over `{title, url, tags}` with the tags in database - * order, and we read them from the very same source, so keeping that order lets - * our locally computed hashes agree with the server's. Sorting here would make - * every bookmark tagged outside floccus hash differently from the server's idea - * of it, and that folder would be re-fetched on every single sync. + * 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 flip side is that a pure reordering counts as a change and gets written - * back once -- which is what makes the two sides agree again. + * 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". @@ -66,7 +69,7 @@ export function normalizeTags(tags?: string[]): string[] | undefined { seen.add(trimmed) normalized.push(trimmed) } - return normalized + return normalized.sort() } /** @@ -182,8 +185,9 @@ export class Bookmark { } 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`), - // so don't reorder or add keys here lightly. + // 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 }) diff --git a/src/test/sync_tags.test.js b/src/test/sync_tags.test.js index 2859859d2a..790cffb641 100644 --- a/src/test/sync_tags.test.js +++ b/src/test/sync_tags.test.js @@ -323,14 +323,16 @@ describe('Floccus', function() { expect([...localBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) }) - it('should converge when tags are only reordered', async function() { - const { localResource, bookmarkId } = await setUpTaggedBookmark(['foo', 'bar']) + 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: ['bar', 'foo'], + tags: ['zzz', 'aaa'], parentId: (await findLocalBookmark(bookmarkUrl)).parentId, location: ItemLocation.LOCAL, })) @@ -338,27 +340,17 @@ describe('Floccus', function() { await account.sync() expect(account.getData().error).to.not.be.ok - // The reorder is written back once so that both sides agree again -- - // no tag may be lost or duplicated in the process. Compared as a set, - // because the order a server hands its tags back in is its own business - // (Nextcloud's findByBookmark() has no ORDER BY, for one). - const serverBookmark = await findServerBookmark(bookmarkUrl) - expect([...serverBookmark.tags].sort()).to.deep.equal(['bar', 'foo']) - - // ...and the next sync has nothing left to do - await account.sync() - expect(account.getData().error).to.not.be.ok - expect([...(await findServerBookmark(bookmarkUrl)).tags].sort()).to.deep.equal(['bar', 'foo']) - expect([...(await findLocalBookmark(bookmarkUrl)).tags].sort()).to.deep.equal(['bar', 'foo']) + expect((await findServerBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) + expect((await findLocalBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) }) - it('should settle when the server returns tags in an order of its own', async function() { + 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(['foo', 'bar']) + 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 @@ -368,7 +360,7 @@ describe('Floccus', function() { ItemType.BOOKMARK, (item) => item.url === bookmarkUrl ) - serverBookmark.tags = ['bar', 'foo'] + serverBookmark.tags = ['zzz', 'aaa'] const serverUpdates = [] const updateBookmark = account.server.updateBookmark.bind(account.server) @@ -377,19 +369,14 @@ describe('Floccus', function() { return updateBookmark(bookmark) } - // The differing order registers as a change once, and the server wins - await account.sync() - expect(account.getData().error).to.not.be.ok - const settledTags = [...(await findLocalBookmark(bookmarkUrl)).tags] - expect(settledTags).to.deep.equal(['bar', 'foo']) - const settledUpdates = serverUpdates.length - - // ...and then it stays put, rather than being rewritten every sync + // 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(settledTags) - expect(serverUpdates.length).to.equal(settledUpdates) + expect((await findLocalBookmark(bookmarkUrl)).tags).to.deep.equal(['aaa', 'zzz']) + expect(serverUpdates).to.deep.equal([]) }) }) }) From d03fab06ccff553b674ddbb6f0d1c8ebcd32142a Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 11 Sep 2026 11:47:53 +0200 Subject: [PATCH 7/8] fix(ci): Increase timeout for Android Appium tests to 80 minutes Signed-off-by: Marcel Klehr --- .github/workflows/android-appium.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 1ca3c28b8b190b3302e8252707664c48c35adf32 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 11 Sep 2026 13:03:55 +0200 Subject: [PATCH 8/8] fix(build): Update doiuse baseline Signed-off-by: Marcel Klehr --- doiuse-report.baseline.txt | 2 ++ 1 file changed, 2 insertions(+) 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)