···11+# Tag Actions Extension — Design Document
22+33+## 1. Concept
44+55+Tag Actions is a new desktop extension that lets users define custom behaviors triggered by tagging items. Each "tag action" binds a tag (or tag pair) to an action — when an item receives that tag, the action fires. This replaces hardcoded tag-based features (like a future "offline" tag triggering content fetch) with a generic, user-configurable system.
66+77+**Examples of tag actions a user could create:**
88+- Tag `offline` triggers: fetch and cache page content for offline reading
99+- Tag `archive` triggers: submit URL to the Wayback Machine
1010+- Tag `read-later` shows: an eyeball icon on items in list views
1111+- Tag `important` shows: a star/flag icon on items
1212+- Tag `summarize` triggers: run a script that summarizes the page content
1313+1414+---
1515+1616+## 2. Extension Structure
1717+1818+### File Layout
1919+2020+```
2121+extensions/tag-actions/
2222+ manifest.json # Extension metadata, commands, shortcuts
2323+ background.html # Entry point (loads background.js as ES module)
2424+ background.js # Core logic: event listeners, action execution
2525+ home.html # Settings/management UI
2626+ home.js # Settings UI logic
2727+ home.css # Settings UI styles
2828+ settings-schema.json # Schema for extension settings (tag action configs)
2929+```
3030+3131+### manifest.json
3232+3333+```json
3434+{
3535+ "id": "tag-actions",
3636+ "shortname": "tag-actions",
3737+ "name": "Tag Actions",
3838+ "description": "Define custom actions triggered by tags",
3939+ "version": "1.0.0",
4040+ "background": "background.html",
4141+ "builtin": true,
4242+ "settingsSchema": "./settings-schema.json",
4343+ "commands": [
4444+ {
4545+ "name": "open tag actions",
4646+ "description": "Open the tag actions manager",
4747+ "action": {
4848+ "type": "window",
4949+ "url": "peek://ext/tag-actions/home.html",
5050+ "options": {
5151+ "role": "workspace",
5252+ "key": "tag-actions-home",
5353+ "width": 800,
5454+ "height": 600,
5555+ "title": "Tag Actions"
5656+ }
5757+ }
5858+ }
5959+ ],
6060+ "shortcuts": [
6161+ {
6262+ "keys": "Option+Shift+T",
6363+ "command": "open tag actions",
6464+ "global": true
6565+ }
6666+ ]
6767+}
6868+```
6969+7070+### background.html
7171+7272+Standard extension entry point pattern:
7373+7474+```html
7575+<!DOCTYPE html>
7676+<html>
7777+<head>
7878+ <script type="module" src="./background.js"></script>
7979+</head>
8080+<body></body>
8181+</html>
8282+```
8383+8484+---
8585+8686+## 3. Data Model
8787+8888+### Storage Location
8989+9090+Tag action configurations are stored in `extension_settings` via `api.settings` (the standard extension settings API). This is the same pattern used by scripts, sheets, and other extensions.
9191+9292+Key: `actions` (JSON array of action configs)
9393+9494+### Tag Action Config Schema
9595+9696+```json
9797+{
9898+ "actions": {
9999+ "type": "array",
100100+ "items": {
101101+ "type": "object",
102102+ "properties": {
103103+ "id": { "type": "string", "description": "Unique ID (generated)" },
104104+ "name": { "type": "string", "description": "User-facing label" },
105105+ "enabled": { "type": "boolean", "default": true },
106106+ "triggerTag": { "type": "string", "description": "Tag name that triggers this action" },
107107+ "filterTag": { "type": "string", "description": "Optional second tag required (AND logic)" },
108108+ "triggerOn": { "type": "string", "enum": ["add", "remove", "both"], "default": "add" },
109109+ "actionType": { "type": "string", "enum": ["icon", "script", "publish", "open-url", "tag"] },
110110+ "actionConfig": { "type": "object", "description": "Action-type-specific configuration" },
111111+ "itemTypes": { "type": "array", "items": { "type": "string" }, "description": "Optional filter: only trigger for these item types (url, text, tagset, etc.)" },
112112+ "createdAt": { "type": "number" },
113113+ "updatedAt": { "type": "number" }
114114+ }
115115+ }
116116+ }
117117+}
118118+```
119119+120120+### settings-schema.json
121121+122122+```json
123123+{
124124+ "labels": {
125125+ "prefs": {
126126+ "autoExecute": "Auto-execute actions on tag add"
127127+ }
128128+ },
129129+ "prefs": {
130130+ "type": "object",
131131+ "properties": {
132132+ "autoExecute": {
133133+ "type": "boolean",
134134+ "description": "Automatically execute actions when tags are added (vs. manual trigger only)",
135135+ "default": true
136136+ }
137137+ }
138138+ },
139139+ "storageKeys": {
140140+ "PREFS": "prefs",
141141+ "ACTIONS": "actions"
142142+ },
143143+ "defaults": {
144144+ "prefs": {
145145+ "autoExecute": true
146146+ },
147147+ "actions": []
148148+ }
149149+}
150150+```
151151+152152+### Example Stored Actions
153153+154154+```json
155155+[
156156+ {
157157+ "id": "ta_1708000000_abc123",
158158+ "name": "Offline Reading",
159159+ "enabled": true,
160160+ "triggerTag": "offline",
161161+ "filterTag": null,
162162+ "triggerOn": "add",
163163+ "actionType": "publish",
164164+ "actionConfig": {
165165+ "topic": "content:fetch-offline",
166166+ "includeItemData": true
167167+ },
168168+ "itemTypes": ["url"],
169169+ "createdAt": 1708000000000,
170170+ "updatedAt": 1708000000000
171171+ },
172172+ {
173173+ "id": "ta_1708000001_def456",
174174+ "name": "Show Read-Later Icon",
175175+ "enabled": true,
176176+ "triggerTag": "read-later",
177177+ "filterTag": null,
178178+ "triggerOn": "add",
179179+ "actionType": "icon",
180180+ "actionConfig": {
181181+ "icon": "eye",
182182+ "color": "#4a9eff",
183183+ "tooltip": "Read later"
184184+ },
185185+ "itemTypes": null,
186186+ "createdAt": 1708000001000,
187187+ "updatedAt": 1708000001000
188188+ },
189189+ {
190190+ "id": "ta_1708000002_ghi789",
191191+ "name": "Archive to Wayback",
192192+ "enabled": true,
193193+ "triggerTag": "archive",
194194+ "filterTag": null,
195195+ "triggerOn": "add",
196196+ "actionType": "open-url",
197197+ "actionConfig": {
198198+ "urlTemplate": "https://web.archive.org/save/{url}",
199199+ "openInBackground": true
200200+ },
201201+ "itemTypes": ["url"],
202202+ "createdAt": 1708000002000,
203203+ "updatedAt": 1708000002000
204204+ }
205205+]
206206+```
207207+208208+---
209209+210210+## 4. Settings UI (home.js)
211211+212212+The settings UI follows the pattern established by groups (home.js) and tags (home.js) — a single-page app with list and detail views, using peek-components.
213213+214214+### UI Structure
215215+216216+```
217217++-------------------------------------------------------+
218218+| Tag Actions [+ New Action] |
219219++-------------------------------------------------------+
220220+| Search actions... |
221221++-------------------------------------------------------+
222222+| |
223223+| [x] Offline Reading offline -> fetch content |
224224+| [x] Read-Later Icon read-later -> eye icon |
225225+| [ ] Archive to Wayback archive -> open URL |
226226+| [x] Run Summarizer summarize -> run script |
227227+| |
228228++-------------------------------------------------------+
229229+```
230230+231231+### Create/Edit Form
232232+233233+When clicking an action or the [+ New Action] button:
234234+235235+```
236236++-------------------------------------------------------+
237237+| < Back |
238238++-------------------------------------------------------+
239239+| Name: [ Offline Reading ] |
240240+| Enabled: [x] |
241241+| |
242242+| --- Trigger --- |
243243+| Tag: [ offline ] (autocomplete from tags) |
244244+| Also requires tag: [ ] (optional AND tag) |
245245+| Trigger on: ( ) Tag added ( ) Tag removed ( ) Both |
246246+| Item types: [x] URL [ ] Text [ ] Tagset [ ] Image |
247247+| |
248248+| --- Action --- |
249249+| Type: [v Publish Event ] |
250250+| |
251251+| (Type-specific config form appears here) |
252252+| Topic: [ content:fetch-offline ] |
253253+| Include item data: [x] |
254254+| |
255255+| [Delete Action] [Save Action] |
256256++-------------------------------------------------------+
257257+```
258258+259259+### Action Type Config Forms
260260+261261+Each action type shows a different config sub-form:
262262+263263+**Icon** — purely visual, no code execution:
264264+- Icon picker (predefined set: eye, star, flag, bookmark, download, archive, clock, check)
265265+- Color picker
266266+- Tooltip text
267267+268268+**Publish** — fires a pubsub event (for other extensions to handle):
269269+- Topic name (string)
270270+- Include item data in payload (boolean)
271271+272272+**Script** — runs an existing user script:
273273+- Script picker (dropdown of scripts from the scripts extension)
274274+275275+**Open URL** — opens a URL template:
276276+- URL template with `{url}`, `{title}`, `{id}` placeholders
277277+- Open in background (boolean)
278278+279279+**Tag** — adds another tag to the item:
280280+- Tag name to add (autocomplete)
281281+282282+### Implementation Notes
283283+284284+- Use `api.datastore.getTagsByFrecency()` to populate tag autocomplete in the trigger field
285285+- Use peek-components (peek-card, peek-input, peek-button, peek-switch, peek-select) per DEVELOPMENT.md guidelines
286286+- Follow the escape handler pattern from groups/tags home.js (list view -> detail view -> back on ESC)
287287+- Persist view state in localStorage (following existing pattern)
288288+289289+---
290290+291291+## 5. Event Flow
292292+293293+### How Tag Additions Trigger Actions
294294+295295+```
296296+User tags item (via cmd, tags UI, groups, sync, etc.)
297297+ |
298298+ v
299299+Backend IPC handler: datastore-tag-item
300300+ |
301301+ v
302302+Backend publishes: tag:item-added { tagId, tagName, itemId, itemType }
303303+ |
304304+ v
305305+tag-actions background.js receives event via api.subscribe('tag:item-added', ...)
306306+ |
307307+ v
308308+Looks up matching actions: actions.filter(a => a.triggerTag === msg.tagName && a.enabled)
309309+ |
310310+ v
311311+For each matching action:
312312+ - Check itemTypes filter (if specified)
313313+ - Check filterTag (if specified, verify item also has this second tag)
314314+ - Execute action based on actionType
315315+```
316316+317317+### background.js Core Logic (Pseudocode)
318318+319319+```javascript
320320+// On init:
321321+api.subscribe('tag:item-added', async (msg) => {
322322+ await handleTagEvent('add', msg);
323323+}, api.scopes.GLOBAL);
324324+325325+api.subscribe('tag:item-removed', async (msg) => {
326326+ await handleTagEvent('remove', msg);
327327+}, api.scopes.GLOBAL);
328328+329329+async function handleTagEvent(eventType, msg) {
330330+ const settings = await api.settings.get();
331331+ const actions = settings.data?.actions || [];
332332+333333+ for (const action of actions) {
334334+ if (!action.enabled) continue;
335335+ if (action.triggerTag !== msg.tagName) continue;
336336+ if (action.triggerOn !== 'both' && action.triggerOn !== eventType) continue;
337337+338338+ // Item type filter
339339+ if (action.itemTypes?.length > 0 && !action.itemTypes.includes(msg.itemType)) continue;
340340+341341+ // Second tag filter (AND logic)
342342+ if (action.filterTag) {
343343+ const itemTags = await api.datastore.getItemTags(msg.itemId);
344344+ if (!itemTags.data?.some(t => t.name === action.filterTag)) continue;
345345+ }
346346+347347+ await executeAction(action, msg);
348348+ }
349349+}
350350+351351+async function executeAction(action, msg) {
352352+ switch (action.actionType) {
353353+ case 'icon':
354354+ // Icons are handled at render time by the item list, not here.
355355+ // Publish an event so list UIs can refresh icon state.
356356+ api.publish('tag-actions:icon-changed', { itemId: msg.itemId }, api.scopes.GLOBAL);
357357+ break;
358358+359359+ case 'publish':
360360+ api.publish(action.actionConfig.topic, {
361361+ itemId: msg.itemId,
362362+ tagName: msg.tagName,
363363+ ...(action.actionConfig.includeItemData ? await getItemData(msg.itemId) : {})
364364+ }, api.scopes.GLOBAL);
365365+ break;
366366+367367+ case 'script':
368368+ api.publish('scripts:execute', {
369369+ scriptId: action.actionConfig.scriptId,
370370+ context: { itemId: msg.itemId, url: msg.itemUrl }
371371+ }, api.scopes.GLOBAL);
372372+ break;
373373+374374+ case 'open-url': {
375375+ const item = await api.datastore.getItem(msg.itemId);
376376+ const url = action.actionConfig.urlTemplate
377377+ .replace('{url}', encodeURIComponent(item.data?.content || ''))
378378+ .replace('{title}', encodeURIComponent(item.data?.title || ''))
379379+ .replace('{id}', msg.itemId);
380380+ api.window.open(url, {
381381+ role: 'content',
382382+ width: 800,
383383+ height: 600
384384+ });
385385+ break;
386386+ }
387387+388388+ case 'tag': {
389389+ const tagResult = await api.datastore.getOrCreateTag(action.actionConfig.addTagName);
390390+ if (tagResult.success) {
391391+ await api.datastore.tagItem(msg.itemId, tagResult.data.tag.id);
392392+ }
393393+ break;
394394+ }
395395+ }
396396+}
397397+```
398398+399399+### Event Ordering Guarantee
400400+401401+The `tag:item-added` event is published synchronously within the IPC handler after the database write completes. The tag-actions extension receives this event in its background.js process. Actions execute asynchronously but are guaranteed to see the tag already persisted in the database.
402402+403403+---
404404+405405+## 6. Item List Integration (Icons/Badges)
406406+407407+### Problem
408408+409409+The tags home.js and groups home.js render item cards. Tag Actions needs to show icons/badges on items that match `icon`-type tag actions. But those UIs are in separate extensions.
410410+411411+### Approach: Pubsub Query Protocol
412412+413413+The tag-actions extension exposes a pubsub-based query API that any item list UI can use:
414414+415415+```javascript
416416+// In tag-actions background.js:
417417+api.subscribe('tag-actions:get-icons', async (msg) => {
418418+ // msg.itemId or msg.itemIds (batch)
419419+ // Returns matching icon configs for items based on their tags
420420+ const icons = await computeIconsForItems(msg.itemIds);
421421+ api.publish('tag-actions:icons-response', { icons }, api.scopes.GLOBAL);
422422+}, api.scopes.GLOBAL);
423423+```
424424+425425+**How it works:**
426426+427427+1. Tag-actions maintains an in-memory index: `tagName -> [icon configs]`
428428+2. When tags home.js renders a card, it can query `tag-actions:get-icons` with the item's tag list
429429+3. The response contains icon configs: `{ itemId: [{ icon: 'eye', color: '#4a9eff', tooltip: 'Read later' }] }`
430430+4. The calling UI renders small SVG icons in the card
431431+432432+### Alternative: CSS Custom Properties + Data Attributes
433433+434434+A simpler approach that avoids cross-extension queries:
435435+436436+1. Tag-actions publishes `tag-actions:icon-rules` on init with the current icon action configs
437437+2. Other UIs listen and store the rules locally
438438+3. When rendering cards, check item tags against rules and render icons inline
439439+440440+This is simpler and avoids async round-trips during render.
441441+442442+### Recommended Approach
443443+444444+Use the CSS/data-attribute approach (alternative above) for v1. It avoids pubsub latency during card rendering and is simpler to implement. The icon rules are small (usually <10 entries) and rarely change.
445445+446446+```javascript
447447+// Tag-actions publishes rules on init and on change:
448448+api.publish('tag-actions:icon-rules', {
449449+ rules: [
450450+ { tag: 'read-later', icon: 'eye', color: '#4a9eff', tooltip: 'Read later' },
451451+ { tag: 'important', icon: 'star', color: '#ffd700', tooltip: 'Important' }
452452+ ]
453453+}, api.scopes.GLOBAL);
454454+455455+// Tags home.js / groups home.js subscribe:
456456+api.subscribe('tag-actions:icon-rules', (msg) => {
457457+ iconRules = msg.rules;
458458+ // Re-render if currently visible
459459+}, api.scopes.GLOBAL);
460460+461461+// During card rendering, check:
462462+const itemTags = state.itemTags.get(item.id) || [];
463463+const icons = iconRules.filter(rule => itemTags.some(t => t.name === rule.tag));
464464+// Render small SVG icons next to the card title
465465+```
466466+467467+---
468468+469469+## 7. Action Types — Initial Set
470470+471471+### 7.1 Icon (Visual Only)
472472+473473+**Purpose:** Show a visual indicator on items with the trigger tag.
474474+475475+**Config:**
476476+- `icon`: One of a predefined set (`eye`, `star`, `flag`, `bookmark`, `download`, `archive`, `clock`, `check`, `heart`, `pin`)
477477+- `color`: Hex color string
478478+- `tooltip`: Hover text
479479+480480+**Execution:** No runtime action. The icon rules are broadcast via pubsub and rendered by item list UIs.
481481+482482+### 7.2 Publish Event
483483+484484+**Purpose:** Fire a pubsub event for other extensions to handle. This is the generic hook for arbitrary integrations.
485485+486486+**Config:**
487487+- `topic`: Pubsub topic name (string)
488488+- `includeItemData`: Whether to include full item data in the payload (boolean)
489489+490490+**Execution:** `api.publish(topic, payload, api.scopes.GLOBAL)`
491491+492492+**Use cases:**
493493+- `content:fetch-offline` could be handled by a future offline extension
494494+- `reader:open` could trigger a reader mode view
495495+- Custom inter-extension communication
496496+497497+### 7.3 Run Script
498498+499499+**Purpose:** Execute an existing user script from the scripts extension.
500500+501501+**Config:**
502502+- `scriptId`: ID of the script to run
503503+504504+**Execution:** Publishes `scripts:execute` with the script ID and item context.
505505+506506+### 7.4 Open URL
507507+508508+**Purpose:** Open a URL template, substituting item data.
509509+510510+**Config:**
511511+- `urlTemplate`: URL with `{url}`, `{title}`, `{id}` placeholders
512512+- `openInBackground`: Whether to focus the new window (boolean)
513513+514514+**Execution:** Substitutes placeholders and calls `api.window.open()`.
515515+516516+**Use cases:**
517517+- Archive to Wayback Machine: `https://web.archive.org/save/{url}`
518518+- Search for related content: `https://www.google.com/search?q={title}`
519519+- Open in a specific service: `https://getpocket.com/save?url={url}`
520520+521521+### 7.5 Add Tag
522522+523523+**Purpose:** Automatically add another tag when the trigger tag is applied.
524524+525525+**Config:**
526526+- `addTagName`: Name of the tag to add
527527+528528+**Execution:** Calls `api.datastore.getOrCreateTag()` then `api.datastore.tagItem()`.
529529+530530+**Use cases:**
531531+- Tag `work-project` auto-adds `work` parent tag
532532+- Tag `recipe` auto-adds `food` category tag
533533+534534+**Guard:** Must check for circular triggers (tag A adds tag B which triggers an action that adds tag A). Implement a per-event execution set to prevent re-entry.
535535+536536+---
537537+538538+## 8. Migration Path
539539+540540+### Current Hardcoded Tag-Based Features
541541+542542+Currently there are no fully-implemented tag-based action systems in the desktop app. The mobile app (`backend/tauri-mobile/src-tauri/src/lib.rs`) has some offline-related code, but desktop does not. This means:
543543+544544+1. **No migration needed for v1.** The tag-actions extension is purely additive.
545545+2. **Future offline reading feature** should be built as a separate extension that listens for a `content:fetch-offline` pubsub event, rather than hardcoding the tag name. Then a default tag action can bridge `offline` tag -> `content:fetch-offline` event.
546546+3. **Groups extension** already uses tag metadata (`isGroup: true`) to distinguish group-tags from regular tags. Tag-actions should not conflict — it operates on a different dimension (what happens when a tag is applied, not what the tag IS).
547547+548548+### Recommended Migration Strategy for Future Features
549549+550550+When building new tag-triggered features:
551551+552552+1. Build the feature as a standalone extension that listens for a pubsub event
553553+2. Create a default tag action (shipped as a preset, not hardcoded) that maps the tag to the event
554554+3. Users can disable, reconfigure, or replace the default tag action
555555+556556+This keeps features decoupled — the offline extension does not need to know about tags, and the tags system does not need to know about offline reading.
557557+558558+### Shipping Default Actions
559559+560560+The extension should include a set of suggested/default actions that users can enable:
561561+562562+```javascript
563563+const PRESET_ACTIONS = [
564564+ {
565565+ name: 'Read Later Icon',
566566+ triggerTag: 'read-later',
567567+ actionType: 'icon',
568568+ actionConfig: { icon: 'eye', color: '#4a9eff', tooltip: 'Read later' }
569569+ },
570570+ {
571571+ name: 'Archive to Wayback Machine',
572572+ triggerTag: 'archive',
573573+ actionType: 'open-url',
574574+ actionConfig: { urlTemplate: 'https://web.archive.org/save/{url}', openInBackground: true },
575575+ itemTypes: ['url']
576576+ }
577577+];
578578+```
579579+580580+On first run (no stored actions), the home.js UI could show these as "suggested actions" the user can add with one click.
581581+582582+---
583583+584584+## 9. Open Questions
585585+586586+### For User Input
587587+588588+1. **Should icon-type actions be toggleable in item lists?** E.g., clicking the eye icon could remove the `read-later` tag (acting as a checkbox). Or should icons be purely visual?
589589+590590+2. **Should tag actions support conditions beyond tag matching?** E.g., "only for URLs matching pattern X" or "only for items created in the last 24 hours". This adds power but also complexity.
591591+592592+3. **Should there be a "confirm before executing" option for destructive actions?** E.g., archiving to Wayback Machine might warrant a confirmation dialog.
593593+594594+4. **What icon set to use?** Options:
595595+ - Inline SVG from a predefined set (simplest, no dependencies)
596596+ - Single emoji character (cross-platform but rendering varies)
597597+ - Reference to an external icon library
598598+599599+5. **Should tag actions support "on schedule" triggers?** E.g., "every day, for items tagged `daily-review`, do X". This is a different trigger model (cron-based vs event-based).
600600+601601+6. **Priority/ordering** — if multiple actions match the same tag event, should users be able to control execution order?
602602+603603+7. **Keyboard shortcut for quick-tagging** — should tag actions expose a way to tag the current window's URL with a single keypress? E.g., Option+1 = tag with `read-later`, Option+2 = tag with `archive`. This could be a feature of the tag-actions extension using `api.shortcuts.register()`.
604604+605605+### Technical Decisions
606606+607607+8. **Circular trigger prevention** — The "add tag" action type can create chains. Proposed solution: maintain a per-event Set of already-triggered action IDs. If an action is already in the set, skip it. Reset the set when the original event handler returns.
608608+609609+9. **Error handling** — If an action fails (e.g., script errors, network timeout on URL open), should the extension:
610610+ - Log silently (current scripts extension pattern)
611611+ - Show a notification
612612+ - Store error state on the action config (like feeds' `errorCount`)
613613+614614+10. **Batch operations** — Sync pulls can import many items at once, each potentially triggering tag actions. Should actions be debounced per tag (process once after all sync items are tagged) or fire for every individual item?
615615+616616+---
617617+618618+## 10. Summary of Patterns Used
619619+620620+| Aspect | Pattern Source | Notes |
621621+|--------|---------------|-------|
622622+| Extension structure | All extensions | manifest.json + background.html + background.js |
623623+| Settings storage | scripts, sheets | `api.settings.get/set` with JSON in `extension_settings` |
624624+| Settings schema | editor, example | `settings-schema.json` with labels, prefs, defaults |
625625+| Settings UI | groups/home.js, tags/home.js | peek-components, ESC navigation, list/detail views |
626626+| Event listening | tags/home.js, groups/home.js | `api.subscribe('tag:item-added', ...)` |
627627+| Cross-extension query | scripts (execute) | Pubsub request/response pattern |
628628+| Noun registration | tags, feeds, scripts | `registerNoun()` for auto-generated commands |
629629+| Command registration | all extensions | `api.commands.register()` in init |
630630+| Window management | feeds, scripts | `api.window.open()` with role/key/dimensions |