experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(tags): canonical URL lookup so page-widget tags attach to existing items

Manually adding a tag via the page-host widget could silently fail when the
URL had not been canonicalized identically to the stored item — query-param
order, default ports, trailing slashes — leaving currentItemId null and
returning early.

Introduce findUrlItem(url) in the datastore which normalizes via the same
canonical form used by addItem('url', ...), expose it through the tile IPC
surface (tile:datastore:find-url-item), and route the tags background helper
through it. addTagToPage now resolves an existing item or creates one on
demand instead of bailing on null id.

+123 -4
+30 -1
app/page/page.js
··· 3486 3486 } 3487 3487 3488 3488 async function addTagToPage(tagName) { 3489 - if (!currentItemId || !tagName) return; 3489 + if (!tagName) return; 3490 3490 tagName = tagName.trim().toLowerCase(); 3491 3491 if (!tagName) return; 3492 + 3493 + // If we don't have an item id yet (page load race, or no item for this 3494 + // URL ever existed), resolve or create one on demand. Previously this 3495 + // function silently returned on null currentItemId, so the user got no 3496 + // feedback when they typed a tag before the page metadata had settled. 3497 + if (!currentItemId) { 3498 + let url = ''; 3499 + try { url = webview.getURL() || ''; } catch { /* ignore */ } 3500 + if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { 3501 + console.warn('[page] addTagToPage: no current item and no http(s) URL to anchor a new one — tag dropped:', tagName); 3502 + return; 3503 + } 3504 + try { 3505 + const found = await api.datastore.findUrlItem(url); 3506 + if (found?.success && found.data) { 3507 + currentItemId = found.data.id; 3508 + } else { 3509 + const created = await api.datastore.addItem('url', { content: url }); 3510 + if (!created?.success || !created.data?.id) { 3511 + console.warn('[page] addTagToPage: failed to create item for URL:', url, created?.error); 3512 + return; 3513 + } 3514 + currentItemId = created.data.id; 3515 + } 3516 + } catch (err) { 3517 + console.error('[page] addTagToPage: error resolving item id:', err); 3518 + return; 3519 + } 3520 + } 3492 3521 3493 3522 // Check if already tagged 3494 3523 if (currentPageTags.some(t => t.name.toLowerCase() === tagName)) {
+44
backend/electron/datastore.test.ts
··· 240 240 }); 241 241 }); 242 242 243 + describe('findUrlItem (canonical URL lookup)', () => { 244 + it('returns null for null/empty/non-http inputs', () => { 245 + assert.strictEqual(datastore.findUrlItem(''), null); 246 + assert.strictEqual(datastore.findUrlItem(null as unknown as string), null); 247 + // Items table only stores url-typed rows for http(s) URLs in practice; 248 + // a non-URL input still returns null because no row will match. 249 + assert.strictEqual(datastore.findUrlItem('not a url'), null); 250 + }); 251 + 252 + it('matches stored item via normalized URL — trailing slash', () => { 253 + const { id } = datastore.addItem('url', { content: 'https://example.com/page' }); 254 + // Active window URL has a trailing slash; stored content does not. 255 + const found = datastore.findUrlItem('https://example.com/page/'); 256 + assert.ok(found, 'should find item despite trailing-slash difference'); 257 + assert.strictEqual(found!.id, id); 258 + }); 259 + 260 + it('matches stored item via normalized URL — query param order', () => { 261 + const { id } = datastore.addItem('url', { content: 'https://example.com/q?a=1&b=2' }); 262 + const found = datastore.findUrlItem('https://example.com/q?b=2&a=1'); 263 + assert.ok(found, 'should find item despite param-order difference'); 264 + assert.strictEqual(found!.id, id); 265 + }); 266 + 267 + it('matches stored item via normalized URL — default port stripped', () => { 268 + const { id } = datastore.addItem('url', { content: 'https://example.com/x' }); 269 + const found = datastore.findUrlItem('https://example.com:443/x'); 270 + assert.ok(found, 'should find item despite default-port presence'); 271 + assert.strictEqual(found!.id, id); 272 + }); 273 + 274 + it('returns null when no item exists for the URL', () => { 275 + const found = datastore.findUrlItem('https://does-not-exist.example/'); 276 + assert.strictEqual(found, null); 277 + }); 278 + 279 + it('does not return soft-deleted items', () => { 280 + const { id } = datastore.addItem('url', { content: 'https://soft-deleted.example/page' }); 281 + datastore.deleteItem(id); 282 + const found = datastore.findUrlItem('https://soft-deleted.example/page'); 283 + assert.strictEqual(found, null); 284 + }); 285 + }); 286 + 243 287 describe('Item deletion', () => { 244 288 it('should soft delete an item', () => { 245 289 const { id } = datastore.addItem('url', { content: 'https://delete-me.com' });
+21
backend/electron/datastore.ts
··· 3150 3150 } 3151 3151 3152 3152 /** 3153 + * Find a URL item by normalizing the input URL and matching item.content 3154 + * exactly. Items are stored with `content` already normalized (via 3155 + * `normalizeUrl`) so the lookup is a single equality check after we 3156 + * normalize the input. Returns null when no item matches — callers that 3157 + * want create-if-missing semantics should layer that on top. 3158 + * 3159 + * Single canonical entry point for "the item for this URL" — fixes the 3160 + * recurring class of bug where one caller does fuzzy/exact matching on 3161 + * the raw URL while another caller stored the normalized form, producing 3162 + * duplicate items for the same logical URL. 3163 + */ 3164 + export function findUrlItem(url: string): Item | null { 3165 + if (!url) return null; 3166 + const normalized = normalizeUrl(url); 3167 + const result = getDb() 3168 + .prepare('SELECT * FROM items WHERE type = ? AND content = ? AND deletedAt = 0') 3169 + .get('url', normalized); 3170 + return (result as Item) || null; 3171 + } 3172 + 3173 + /** 3153 3174 * Extract inline hashtags from text content and add them as tags on the item. 3154 3175 * Only adds — never removes (can't distinguish manual tags from hashtag-derived). 3155 3176 */
+2
backend/electron/tile-api.d.ts
··· 234 234 // Item operations (require 'items' table grant) 235 235 addItem(type: string, options?: Record<string, unknown>): Promise<TileDatastoreResult>; 236 236 getItem(id: string): Promise<TileDatastoreResult>; 237 + /** Find a URL item by canonical (normalized) URL match. Returns null when no item exists. */ 238 + findUrlItem(url: string): Promise<TileDatastoreResult>; 237 239 updateItem(id: string, options: Record<string, unknown>): Promise<TileDatastoreResult>; 238 240 deleteItem(id: string): Promise<TileDatastoreResult>; 239 241 queryItems(filter?: Record<string, unknown>): Promise<TileDatastoreResult<unknown[]>>;
+14
backend/electron/tile-ipc.ts
··· 44 44 getRow as dsGetRow, 45 45 addItem as dsAddItem, 46 46 getItem as dsGetItem, 47 + findUrlItem as dsFindUrlItem, 47 48 updateItem as dsUpdateItem, 48 49 deleteItem as dsDeleteItem, 49 50 queryItems as dsQueryItems, ··· 5230 5231 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-item', check.error); return { error: check.error }; } 5231 5232 try { 5232 5233 const data = dsGetItem(args.id); 5234 + return { success: true, data }; 5235 + } catch (error) { 5236 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 5237 + } 5238 + }); 5239 + 5240 + registerTileIpc('tile:datastore:find-url-item', { mode: 'handle' }, async (event, args: { 5241 + token: string; 5242 + url: string; 5243 + }, _grant) => { const check = validateTileDatastoreRequest(args?.token, ['items']); 5244 + if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:find-url-item', check.error); return { error: check.error }; } 5245 + try { 5246 + const data = dsFindUrlItem(args.url); 5233 5247 return { success: true, data }; 5234 5248 } catch (error) { 5235 5249 return { success: false, error: error instanceof Error ? error.message : String(error) };
+2
backend/electron/tile-preload.cts
··· 1044 1044 ipcRenderer.invoke('tile:datastore:add-item', { token: tileToken, type, options }), 1045 1045 getItem: (id: string) => 1046 1046 ipcRenderer.invoke('tile:datastore:get-item', { token: tileToken, id }), 1047 + findUrlItem: (url: string) => 1048 + ipcRenderer.invoke('tile:datastore:find-url-item', { token: tileToken, url }), 1047 1049 updateItem: (id: string, options: unknown) => 1048 1050 ipcRenderer.invoke('tile:datastore:update-item', { token: tileToken, id, options }), 1049 1051 deleteItem: (id: string) =>
+10 -3
features/tags/background.js
··· 89 89 * Find item record by URL content 90 90 */ 91 91 const findItemByUrl = async (url) => { 92 - const result = await api.datastore.queryItems({ type: 'url', search: url, limit: 5 }); 92 + // Use the canonical findUrlItem — normalizes the URL server-side and 93 + // matches against item.content (also stored normalized). Previously 94 + // this did a fuzzy queryItems search + strict `=== url` filter, which 95 + // returned null whenever the active window's URL differed from the 96 + // stored normalized form (trailing slash, default port, query order). 97 + // That caused the tag command to create a duplicate item, which the 98 + // page-host's tag-event subscriber then adopted as currentItemId — 99 + // making it look like existing tags had been replaced. 100 + const result = await api.datastore.findUrlItem(url); 93 101 if (!result.success) return null; 94 - 95 - return result.data.find(item => item.content === url) || null; 102 + return result.data || null; 96 103 }; 97 104 98 105 /**