experiments in a post-browser web
10
fork

Configure Feed

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

groups, history, frecency

+1029 -406
+84 -79
app/cmd/commands/groups.js
··· 1 1 /** 2 - * Groups command - save and open window groups using machine tags 3 - * Groups are stored as machine tags on addresses: "group:groupname" 2 + * Groups command - manage groups (tags) and their addresses 3 + * Groups are implemented as tags in the datastore 4 4 */ 5 5 import windows from '../../windows.js'; 6 6 import api from '../../api.js'; 7 7 8 - const GROUP_TAG_PREFIX = 'group:'; 9 - 10 - /** 11 - * Helper to add a tag to an address's tags string 12 - */ 13 - const addTagToAddress = async (addressId, tag, currentTags) => { 14 - const tagsArray = currentTags ? currentTags.split(',').map(t => t.trim()).filter(t => t) : []; 15 - if (!tagsArray.includes(tag)) { 16 - tagsArray.push(tag); 17 - } 18 - const newTags = tagsArray.join(','); 19 - return api.datastore.updateAddress(addressId, { tags: newTags }); 20 - }; 8 + const GROUPS_ADDRESS = 'peek://app/groups/home.html'; 21 9 22 10 /** 23 11 * Helper to get or create an address for a URI ··· 37 25 }; 38 26 39 27 /** 40 - * Get all group names from existing tags 41 - */ 42 - const getGroupNames = async () => { 43 - const result = await api.datastore.queryAddresses({}); 44 - if (!result.success) return []; 45 - 46 - const groupNames = new Set(); 47 - result.data.forEach(addr => { 48 - if (addr.tags) { 49 - addr.tags.split(',').forEach(tag => { 50 - const trimmed = tag.trim(); 51 - if (trimmed.startsWith(GROUP_TAG_PREFIX)) { 52 - groupNames.add(trimmed.substring(GROUP_TAG_PREFIX.length)); 53 - } 54 - }); 55 - } 56 - }); 57 - 58 - return Array.from(groupNames); 59 - }; 60 - 61 - /** 62 - * Get addresses in a group 28 + * Get all tags (groups) sorted by frecency 63 29 */ 64 - const getGroupAddresses = async (groupName) => { 65 - const tag = GROUP_TAG_PREFIX + groupName; 66 - const result = await api.datastore.queryAddresses({ tag }); 30 + const getAllGroups = async () => { 31 + const result = await api.datastore.getTagsByFrecency(); 67 32 if (!result.success) return []; 68 33 return result.data; 69 34 }; 70 35 71 36 /** 72 - * Save current windows as a group 37 + * Save current windows to a group (tag) 73 38 */ 74 - const saveGroup = async (groupName) => { 75 - console.log('Saving group:', groupName); 39 + const saveToGroup = async (groupName) => { 40 + console.log('Saving to group:', groupName); 41 + 42 + // Get or create the tag 43 + const tagResult = await api.datastore.getOrCreateTag(groupName); 44 + if (!tagResult.success) { 45 + console.error('Failed to get/create tag:', tagResult.error); 46 + return { success: false, error: tagResult.error }; 47 + } 48 + 49 + const tagId = tagResult.data.id; 76 50 77 51 // Get all open windows (excluding internal peek:// URLs) 78 - const listResult = await api.window.list(); 52 + const listResult = await api.window.list({ includeInternal: false }); 79 53 if (!listResult.success || listResult.windows.length === 0) { 80 54 console.log('No windows to save'); 81 55 return { success: false, error: 'No windows to save' }; 82 56 } 83 57 84 - const tag = GROUP_TAG_PREFIX + groupName; 85 58 let savedCount = 0; 86 59 87 60 for (const win of listResult.windows) { 88 61 const addr = await getOrCreateAddress(win.url); 89 62 if (addr) { 90 - await addTagToAddress(addr.id, tag, addr.tags || ''); 91 - savedCount++; 63 + const linkResult = await api.datastore.tagAddress(addr.id, tagId); 64 + if (linkResult.success && !linkResult.alreadyExists) { 65 + savedCount++; 66 + } 92 67 } 93 68 } 94 69 95 70 console.log(`Saved ${savedCount} addresses to group "${groupName}"`); 96 - return { success: true, count: savedCount }; 71 + return { success: true, count: savedCount, total: listResult.windows.length }; 97 72 }; 98 73 99 74 /** 100 - * Open all addresses in a group 75 + * Open all addresses in a group (tag) 101 76 */ 102 77 const openGroup = async (groupName) => { 103 78 console.log('Opening group:', groupName); 104 79 105 - const addresses = await getGroupAddresses(groupName); 106 - if (addresses.length === 0) { 80 + // Find the tag by name 81 + const tagsResult = await api.datastore.getTagsByFrecency(); 82 + if (!tagsResult.success) { 83 + return { success: false, error: 'Failed to get tags' }; 84 + } 85 + 86 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase()); 87 + if (!tag) { 88 + console.log('Group not found:', groupName); 89 + return { success: false, error: 'Group not found' }; 90 + } 91 + 92 + // Get addresses with this tag 93 + const addressesResult = await api.datastore.getAddressesByTag(tag.id); 94 + if (!addressesResult.success || addressesResult.data.length === 0) { 107 95 console.log('No addresses in group:', groupName); 108 - return { success: false, error: 'Group is empty or not found' }; 96 + return { success: false, error: 'Group is empty' }; 109 97 } 110 98 111 - for (const addr of addresses) { 99 + for (const addr of addressesResult.data) { 112 100 await windows.createWindow(addr.uri, { 113 101 trackingSource: 'cmd', 114 102 trackingSourceId: `group:${groupName}` 115 103 }); 116 104 } 117 105 118 - console.log(`Opened ${addresses.length} windows from group "${groupName}"`); 119 - return { success: true, count: addresses.length }; 106 + console.log(`Opened ${addressesResult.data.length} windows from group "${groupName}"`); 107 + return { success: true, count: addressesResult.data.length }; 120 108 }; 121 109 122 - // Base commands 110 + // Commands 123 111 const commands = [ 124 112 { 113 + name: 'groups', 114 + description: 'Open the groups manager', 115 + async execute(ctx) { 116 + console.log('Opening groups manager'); 117 + await windows.createWindow(GROUPS_ADDRESS, { 118 + width: 800, 119 + height: 600, 120 + trackingSource: 'cmd', 121 + trackingSourceId: 'groups' 122 + }); 123 + } 124 + }, 125 + { 125 126 name: 'save group', 127 + description: 'Save open windows to a group', 126 128 async execute(ctx) { 127 129 if (ctx.search) { 128 - const groupName = ctx.search.trim().replace(/\s+/g, '-').toLowerCase(); 129 - await saveGroup(groupName); 130 + const groupName = ctx.search.trim(); 131 + const result = await saveToGroup(groupName); 132 + if (result.success) { 133 + console.log(`Saved ${result.count} of ${result.total} windows to "${groupName}"`); 134 + } 130 135 } else { 131 136 console.log('Usage: save group <name>'); 132 137 } 133 138 } 134 - } 135 - ]; 136 - 137 - /** 138 - * Initialize dynamic group commands 139 - * Adds "open group <name>" commands for each saved group 140 - */ 141 - export const initializeSources = async (addCommand) => { 142 - const groupNames = await getGroupNames(); 143 - console.log('Found groups:', groupNames); 144 - 145 - groupNames.forEach(groupName => { 146 - addCommand({ 147 - name: `open group ${groupName}`, 148 - async execute(ctx) { 139 + }, 140 + { 141 + name: 'open group', 142 + description: 'Open all addresses in a group', 143 + async execute(ctx) { 144 + if (ctx.search) { 145 + const groupName = ctx.search.trim(); 149 146 await openGroup(groupName); 147 + } else { 148 + // Show available groups 149 + const groups = await getAllGroups(); 150 + if (groups.length === 0) { 151 + console.log('No groups saved yet. Use "save group <name>" to create one.'); 152 + } else { 153 + console.log('Available groups:'); 154 + groups.forEach(g => console.log(' -', g.name)); 155 + } 150 156 } 151 - }); 152 - }); 153 - }; 157 + } 158 + } 159 + ]; 154 160 155 161 export default { 156 - commands, 157 - initializeSources 162 + commands 158 163 };
+3 -2
app/cmd/commands/index.js
··· 7 7 import groupsModule from './groups.js'; 8 8 import noteModule from './note.js'; 9 9 import historyModule from './history.js'; 10 + import tagModule from './tag.js'; 10 11 11 12 // Source commands (commented out as they need browser extension APIs) 12 13 // These modules contain command sources that dynamically generate commands ··· 27 28 modalCommand, 28 29 ...groupsModule.commands, 29 30 ...noteModule.commands, 30 - ...historyModule.commands 31 + ...historyModule.commands, 32 + ...tagModule.commands 31 33 ]; 32 34 33 35 // Inactive commands - these require browser extension APIs and are not loaded ··· 49 51 50 52 // Source commands - these are modules that generate multiple commands dynamically 51 53 const sources = [ 52 - groupsModule, 53 54 historyModule 54 55 ]; 55 56
+262
app/cmd/commands/tag.js
··· 1 + /** 2 + * Tag command - add tags to the URL of the active window 3 + * Tags are saved using the proper join table (address_tags) with frecency tracking 4 + * 5 + * Usage: 6 + * tag foo - add tag "foo" to active window's URL 7 + * tag foo bar - add multiple tags 8 + * tag -r foo - remove tag "foo" from active window 9 + * tag - show tags for active window 10 + */ 11 + import api from '../../api.js'; 12 + 13 + /** 14 + * Get the most recently focused non-internal window 15 + */ 16 + const getActiveWindow = async () => { 17 + const result = await api.window.list({ includeInternal: false }); 18 + if (!result.success || !result.windows.length) { 19 + return null; 20 + } 21 + // Return the first non-internal window 22 + return result.windows[0]; 23 + }; 24 + 25 + /** 26 + * Find address record by URI 27 + */ 28 + const findAddressByUri = async (uri) => { 29 + const result = await api.datastore.queryAddresses({}); 30 + if (!result.success) return null; 31 + 32 + return result.data.find(addr => addr.uri === uri) || null; 33 + }; 34 + 35 + /** 36 + * Add tags to an address using the join table 37 + */ 38 + const addTagsToAddress = async (addressId, tagNames) => { 39 + const results = []; 40 + 41 + for (const tagName of tagNames) { 42 + // Get or create the tag 43 + const tagResult = await api.datastore.getOrCreateTag(tagName); 44 + if (!tagResult.success) { 45 + console.error('Failed to get/create tag:', tagName, tagResult.error); 46 + continue; 47 + } 48 + 49 + // Link tag to address 50 + const linkResult = await api.datastore.tagAddress(addressId, tagResult.data.id); 51 + if (!linkResult.success) { 52 + console.error('Failed to link tag:', tagName, linkResult.error); 53 + continue; 54 + } 55 + 56 + results.push({ 57 + tag: tagResult.data, 58 + alreadyExists: linkResult.alreadyExists 59 + }); 60 + } 61 + 62 + return results; 63 + }; 64 + 65 + /** 66 + * Remove tags from an address 67 + */ 68 + const removeTagsFromAddress = async (addressId, tagNames) => { 69 + const results = []; 70 + 71 + // Get current tags for address 72 + const tagsResult = await api.datastore.getAddressTags(addressId); 73 + if (!tagsResult.success) { 74 + return results; 75 + } 76 + 77 + for (const tagName of tagNames) { 78 + // Find the tag by name 79 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === tagName.toLowerCase()); 80 + if (!tag) { 81 + console.log('Tag not found on address:', tagName); 82 + continue; 83 + } 84 + 85 + // Unlink tag from address 86 + const unlinkResult = await api.datastore.untagAddress(addressId, tag.id); 87 + results.push({ 88 + tag, 89 + removed: unlinkResult.removed 90 + }); 91 + } 92 + 93 + return results; 94 + }; 95 + 96 + /** 97 + * Get tags for an address 98 + */ 99 + const getTagsForAddress = async (addressId) => { 100 + const result = await api.datastore.getAddressTags(addressId); 101 + if (!result.success) return []; 102 + return result.data; 103 + }; 104 + 105 + // Commands 106 + const commands = [ 107 + { 108 + name: 'tag', 109 + description: 'Add tags to the active window URL', 110 + async execute(ctx) { 111 + // Get active window 112 + const activeWindow = await getActiveWindow(); 113 + if (!activeWindow) { 114 + console.log('No active window found'); 115 + return { success: false, error: 'No active window' }; 116 + } 117 + 118 + const url = activeWindow.url; 119 + console.log('Tagging URL:', url); 120 + 121 + // Find address in datastore 122 + let address = await findAddressByUri(url); 123 + 124 + // If no address exists, create one 125 + if (!address) { 126 + const addResult = await api.datastore.addAddress(url, { 127 + title: activeWindow.title || '' 128 + }); 129 + if (!addResult.success) { 130 + console.error('Failed to create address:', addResult.error); 131 + return { success: false, error: 'Failed to create address' }; 132 + } 133 + address = { id: addResult.id }; 134 + } 135 + 136 + // No args - show current tags 137 + if (!ctx.search) { 138 + const tags = await getTagsForAddress(address.id); 139 + if (tags.length === 0) { 140 + console.log('No tags for:', url); 141 + } else { 142 + console.log('Tags for', url + ':'); 143 + tags.forEach(t => console.log(' -', t.name, `(frecency: ${t.frecencyScore?.toFixed(1) || 0})`)); 144 + } 145 + return { success: true, tags }; 146 + } 147 + 148 + // Parse args 149 + const args = ctx.search.trim().split(/\s+/); 150 + const removeMode = args[0] === '-r'; 151 + const tagsToProcess = removeMode ? args.slice(1) : args; 152 + 153 + if (tagsToProcess.length === 0) { 154 + console.log('No tags specified'); 155 + return { success: false, error: 'No tags specified' }; 156 + } 157 + 158 + // Add or remove tags 159 + if (removeMode) { 160 + const results = await removeTagsFromAddress(address.id, tagsToProcess); 161 + const removed = results.filter(r => r.removed).map(r => r.tag.name); 162 + if (removed.length > 0) { 163 + console.log('Removed tags:', removed.join(', '), 'from', url); 164 + } 165 + return { success: true, removed }; 166 + } else { 167 + const results = await addTagsToAddress(address.id, tagsToProcess); 168 + const added = results.filter(r => !r.alreadyExists).map(r => r.tag.name); 169 + const existing = results.filter(r => r.alreadyExists).map(r => r.tag.name); 170 + if (added.length > 0) { 171 + console.log('Added tags:', added.join(', '), 'to', url); 172 + } 173 + if (existing.length > 0) { 174 + console.log('Already tagged:', existing.join(', ')); 175 + } 176 + return { success: true, added, existing }; 177 + } 178 + } 179 + }, 180 + { 181 + name: 'tags', 182 + description: 'Show tags for the active window URL (or all tags by frecency)', 183 + async execute(ctx) { 184 + // If search term provided, show all tags matching 185 + if (ctx.search) { 186 + const result = await api.datastore.getTagsByFrecency(); 187 + if (!result.success) { 188 + console.log('Failed to get tags'); 189 + return { success: false }; 190 + } 191 + 192 + const filter = ctx.search.toLowerCase(); 193 + const filtered = result.data.filter(t => t.name.toLowerCase().includes(filter)); 194 + 195 + if (filtered.length === 0) { 196 + console.log('No tags matching:', ctx.search); 197 + } else { 198 + console.log('Tags matching "' + ctx.search + '":'); 199 + filtered.forEach(t => { 200 + console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`); 201 + }); 202 + } 203 + return { success: true, tags: filtered }; 204 + } 205 + 206 + // No args - show tags for active window 207 + const activeWindow = await getActiveWindow(); 208 + if (!activeWindow) { 209 + // No active window - show all tags by frecency 210 + const result = await api.datastore.getTagsByFrecency(); 211 + if (!result.success) { 212 + console.log('Failed to get tags'); 213 + return { success: false }; 214 + } 215 + 216 + if (result.data.length === 0) { 217 + console.log('No tags yet'); 218 + } else { 219 + console.log('All tags (by frecency):'); 220 + result.data.slice(0, 20).forEach(t => { 221 + console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`); 222 + }); 223 + } 224 + return { success: true, tags: result.data }; 225 + } 226 + 227 + const address = await findAddressByUri(activeWindow.url); 228 + if (!address) { 229 + console.log('No tags for:', activeWindow.url); 230 + return { success: true, tags: [] }; 231 + } 232 + 233 + const tags = await getTagsForAddress(address.id); 234 + 235 + if (tags.length === 0) { 236 + console.log('No tags for:', activeWindow.url); 237 + } else { 238 + console.log('Tags for', activeWindow.url + ':'); 239 + tags.forEach(t => console.log(' -', t.name)); 240 + } 241 + 242 + return { success: true, tags }; 243 + } 244 + }, 245 + { 246 + name: 'untag', 247 + description: 'Remove tags from the active window URL', 248 + async execute(ctx) { 249 + if (!ctx.search) { 250 + console.log('Usage: untag <tag1> [tag2] ...'); 251 + return { success: false, error: 'No tags specified' }; 252 + } 253 + 254 + // Delegate to tag -r 255 + return commands[0].execute({ search: '-r ' + ctx.search }); 256 + } 257 + } 258 + ]; 259 + 260 + export default { 261 + commands 262 + };
+24 -1
app/datastore/schema.js
··· 96 96 metadata: { type: 'string', default: '{}' }, 97 97 createdAt: { type: 'number' }, 98 98 updatedAt: { type: 'number' }, 99 - usageCount: { type: 'number', default: 0 } 99 + frequency: { type: 'number', default: 0 }, 100 + lastUsedAt: { type: 'number', default: 0 }, 101 + frecencyScore: { type: 'number', default: 0 } 102 + }, 103 + 104 + // Join table for address-tag relationships 105 + address_tags: { 106 + addressId: { type: 'string' }, 107 + tagId: { type: 'string' }, 108 + createdAt: { type: 'number' } 100 109 }, 101 110 102 111 blobs: { ··· 203 212 tags_byParent: { 204 213 table: 'tags', 205 214 on: 'parentId' 215 + }, 216 + tags_byFrecency: { 217 + table: 'tags', 218 + on: 'frecencyScore' 219 + }, 220 + 221 + // Address-tag join indexes 222 + address_tags_byAddress: { 223 + table: 'address_tags', 224 + on: 'addressId' 225 + }, 226 + address_tags_byTag: { 227 + table: 'address_tags', 228 + on: 'tagId' 206 229 }, 207 230 208 231 // Blob indexes
+175 -64
app/groups/home.css
··· 1 - .controls { 2 - } 3 - 4 - .controls.tabsview { 5 - display: none; 1 + * { 2 + box-sizing: border-box; 3 + margin: 0; 4 + padding: 0; 6 5 } 7 6 8 7 html { 9 - background: #f5f7f8; 10 - font-family: 'Roboto', sans-serif; 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 11 9 -webkit-font-smoothing: antialiased; 12 - padding: 20px 0; 10 + font-size: 14px; 11 + line-height: 1.5; 13 12 } 14 13 15 - .cards { 16 - width: 90%; 17 - max-width: 1240px; 18 - margin: 0 auto; 19 - 20 - display: grid; 21 - 22 - grid-template-columns: 1fr; 23 - grid-template-rows: auto; 24 - grid-gap: 20px; 14 + body { 15 + background: #1a1a1a; 16 + color: #e0e0e0; 17 + min-height: 100vh; 25 18 } 26 19 27 - @media only screen and (min-width: 500px) { 28 - .cards { 29 - grid-template-columns: 1fr 1fr; 30 - } 20 + /* Header */ 21 + .header { 22 + display: flex; 23 + align-items: center; 24 + justify-content: space-between; 25 + padding: 16px 24px; 26 + border-bottom: 1px solid #333; 27 + background: #222; 28 + position: sticky; 29 + top: 0; 30 + z-index: 100; 31 31 } 32 32 33 - @media only screen and (min-width: 850px) { 34 - .cards { 35 - grid-template-columns: 1fr 1fr 1fr 1fr; 36 - } 33 + .header-title { 34 + font-size: 18px; 35 + font-weight: 600; 36 + color: #fff; 37 + flex: 1; 38 + text-align: center; 37 39 } 38 40 39 - /* card */ 41 + .back-btn, 42 + .new-group-btn { 43 + padding: 8px 16px; 44 + border: none; 45 + border-radius: 6px; 46 + font-size: 14px; 47 + font-weight: 500; 48 + cursor: pointer; 49 + transition: all 0.15s ease; 50 + } 40 51 52 + .back-btn { 53 + background: #333; 54 + color: #e0e0e0; 55 + } 56 + 57 + .back-btn:hover { 58 + background: #444; 59 + } 60 + 61 + .new-group-btn { 62 + background: #0066cc; 63 + color: #fff; 64 + } 65 + 66 + .new-group-btn:hover { 67 + background: #0077ee; 68 + } 69 + 70 + /* Cards container */ 71 + .cards { 72 + padding: 24px; 73 + display: grid; 74 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 75 + gap: 16px; 76 + } 77 + 78 + /* Card base */ 41 79 .card { 42 - min-height: 100%; 43 - background: white; 44 - box-shadow: 0 2px 5px rgba(0,0,0,0.1); 80 + background: #252525; 81 + border-radius: 8px; 82 + padding: 16px; 83 + cursor: pointer; 84 + transition: all 0.15s ease; 45 85 display: flex; 46 - flex-direction: column; 47 - text-decoration: none; 48 - color: #444; 49 - position: relative; 50 - top: 0; 51 - transition: all .1s ease-in; 86 + align-items: flex-start; 87 + gap: 12px; 52 88 } 53 89 54 90 .card:hover { 55 - top: -2px; 56 - box-shadow: 0 4px 5px rgba(0,0,0,0.2); 91 + background: #2a2a2a; 92 + transform: translateY(-2px); 93 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 94 + } 95 + 96 + /* Group card */ 97 + .group-card .color-dot { 98 + width: 12px; 99 + height: 12px; 100 + border-radius: 50%; 101 + flex-shrink: 0; 102 + margin-top: 4px; 57 103 } 58 104 59 - .card article { 60 - padding: 20px; 61 - display: flex; 62 - 105 + /* Address card */ 106 + .address-card .card-favicon { 107 + width: 32px; 108 + height: 32px; 109 + border-radius: 4px; 110 + flex-shrink: 0; 111 + background: #333; 112 + object-fit: contain; 113 + } 114 + 115 + /* Card content */ 116 + .card-content { 63 117 flex: 1; 64 - justify-content: space-between; 65 - flex-direction: column; 66 - 118 + min-width: 0; 67 119 } 68 - .card .thumb { 69 - padding-bottom: 60%; 70 - background-size: cover; 71 - background-position: center center; 120 + 121 + .card-title { 122 + font-size: 15px; 123 + font-weight: 600; 124 + color: #fff; 125 + margin-bottom: 4px; 126 + white-space: nowrap; 127 + overflow: hidden; 128 + text-overflow: ellipsis; 72 129 } 73 130 74 - .card p { 75 - flex: 1; /* make p grow to fill available space*/ 76 - line-height: 1.4; 131 + .card-url { 132 + font-size: 12px; 133 + color: #888; 134 + white-space: nowrap; 135 + overflow: hidden; 136 + text-overflow: ellipsis; 137 + margin-bottom: 8px; 77 138 } 78 139 79 - /* typography */ 80 - h1 { 81 - font-size: 20px; 82 - margin: 0; 83 - color: #333; 140 + .card-meta { 141 + font-size: 12px; 142 + color: #666; 143 + } 144 + 145 + /* Empty state */ 146 + .empty-state { 147 + grid-column: 1 / -1; 148 + text-align: center; 149 + padding: 48px 24px; 150 + color: #666; 151 + font-size: 15px; 84 152 } 85 153 86 - .card span { 87 - font-size: 12px; 88 - font-weight: bold; 89 - color: #999; 90 - text-transform: uppercase; 91 - letter-spacing: .05em; 92 - margin: 2em 0 0 0; 154 + /* Dark mode support (already dark, but for consistency) */ 155 + @media (prefers-color-scheme: light) { 156 + body { 157 + background: #f5f5f5; 158 + color: #333; 159 + } 160 + 161 + .header { 162 + background: #fff; 163 + border-bottom-color: #e0e0e0; 164 + } 165 + 166 + .header-title { 167 + color: #333; 168 + } 169 + 170 + .back-btn { 171 + background: #e0e0e0; 172 + color: #333; 173 + } 174 + 175 + .back-btn:hover { 176 + background: #d0d0d0; 177 + } 178 + 179 + .card { 180 + background: #fff; 181 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 182 + } 183 + 184 + .card:hover { 185 + background: #fafafa; 186 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 187 + } 188 + 189 + .card-title { 190 + color: #333; 191 + } 192 + 193 + .card-url { 194 + color: #666; 195 + } 196 + 197 + .card-meta { 198 + color: #999; 199 + } 200 + 201 + .address-card .card-favicon { 202 + background: #f0f0f0; 203 + } 93 204 }
+8 -22
app/groups/home.html
··· 3 3 <head> 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 6 - 7 - <title>ESCAPE</title> 8 - 9 - <script src="home.js"></script> 10 - 6 + <title>Groups</title> 11 7 <link rel="stylesheet" type="text/css" href="home.css"> 12 - 13 8 </head> 14 9 <body> 15 - 16 - <div class="controls tabsview"> 17 - <button class="newgroup">[+] new group</button> 18 - </div> 19 - 20 - <div class="cards"> 21 - </div> 10 + <header class="header"> 11 + <button class="back-btn" style="display: none;">Back</button> 12 + <h1 class="header-title">Groups</h1> 13 + <button class="new-group-btn">+ New Group</button> 14 + </header> 22 15 23 - <template class="tpl-card"> 24 - <div class="card"> 25 - <div class="thumb" style="background-image: url();"></div> 26 - <article> 27 - <h1></h1> 28 - <span></span> 29 - </article> 30 - </div> 31 - </template> 16 + <main class="cards"></main> 32 17 18 + <script type="module" src="home.js"></script> 33 19 </body> 34 20 </html>
+190 -226
app/groups/home.js
··· 1 - /* 2 - 3 - groups 4 - * there's always a default group 1 + /** 2 + * Groups - Tag-based grouping of addresses 3 + * 4 + * Groups are implemented using tags: 5 + * - Each "group" is a tag 6 + * - Addresses in a group are addresses tagged with that tag 7 + * - Creating a new group creates a new tag 8 + * - Viewing a group shows all addresses with that tag 9 + */ 5 10 6 - managing storage 7 - * scaling to lots of pages/groups 8 - * collect all on startup 9 - * manage cache 10 - * what can change? 11 - * new page in group 12 - * page changes between groups 13 - * page closed 14 - * undo close page to a group that no longer exists (use current group) 11 + const api = window.app; 12 + const debug = api.debug; 15 13 16 - relevant page events 17 - * new page 18 - * add to current group 19 - * activate (and switch active group) 20 - * close page 14 + // View states 15 + const VIEW_GROUPS = 'groups'; 16 + const VIEW_ADDRESSES = 'addresses'; 21 17 22 - */ 18 + let state = { 19 + view: VIEW_GROUPS, 20 + tags: [], 21 + currentTag: null, 22 + addresses: [] 23 + }; 23 24 24 - (async () => { 25 + // Handle ESC - go back to groups view 26 + document.onkeydown = (evt) => { 27 + if (evt.key === 'Escape') { 28 + if (state.view === VIEW_ADDRESSES) { 29 + showGroups(); 30 + } 31 + } 32 + }; 25 33 26 - // TODO: make extensible 27 - const VIEW_GROUPS = 1; 28 - const VIEW_TABS = 2; 34 + const init = async () => { 35 + debug && console.log('Groups init'); 29 36 30 - // keys for extension-level data 31 - const EXT_DATA_CONFIG_KEY = 'config'; 37 + // Load tags from datastore 38 + await loadTags(); 32 39 33 - // keys for per-tab data 34 - const TAB_DATA_GROUP_KEY = 'groupId'; 40 + // Set up event listeners 41 + document.querySelector('.new-group-btn').addEventListener('click', createNewGroup); 42 + document.querySelector('.back-btn').addEventListener('click', showGroups); 35 43 36 - // default strings 37 - // TODO: move to i18n 38 - const DEFAULT_GROUP_TITLE = 'Default Group'; 39 - const DEFAULT_NEW_GROUP_TITLE = 'New Group'; 44 + // Show groups view 45 + showGroups(); 46 + }; 40 47 41 - // Handle ESC 42 - document.onkeydown = function(evt) { 43 - evt = evt || window.event; 44 - var isEscape = evt.key == 'Escape'; 45 - if (isEscape && currentView == VIEW_TABS) { 46 - showGroups(); 48 + /** 49 + * Load all tags sorted by frecency 50 + */ 51 + const loadTags = async () => { 52 + const result = await api.datastore.getTagsByFrecency(); 53 + if (result.success) { 54 + state.tags = result.data; 55 + debug && console.log('Loaded tags:', state.tags.length); 56 + } else { 57 + console.error('Failed to load tags:', result.error); 58 + state.tags = []; 47 59 } 48 60 }; 49 61 50 - var currentView = null; 51 - 52 - var config = null; 53 - 54 - const init = () => { 55 - // Initialize storage - this loads groups, everything else 56 - initStorage(); 62 + /** 63 + * Load addresses for a specific tag 64 + */ 65 + const loadAddressesForTag = async (tagId) => { 66 + const result = await api.datastore.getAddressesByTag(tagId); 67 + if (result.success) { 68 + state.addresses = result.data; 69 + debug && console.log('Loaded addresses for tag:', state.addresses.length); 70 + } else { 71 + console.error('Failed to load addresses:', result.error); 72 + state.addresses = []; 73 + } 74 + }; 57 75 58 - // Data loaded, start building UI 59 - //populateUI(); 76 + /** 77 + * Create a new group (tag) 78 + */ 79 + const createNewGroup = async () => { 80 + const name = prompt('Enter group name:'); 81 + if (!name || !name.trim()) return; 60 82 61 - // New group click handler 62 - document.querySelector('.newgroup').addEventListener('click', function() { 63 - newGroup(); 64 - }); 83 + const result = await api.datastore.getOrCreateTag(name.trim()); 84 + if (result.success) { 85 + debug && console.log('Created tag:', result.data); 86 + await loadTags(); 87 + showGroups(); 88 + } else { 89 + console.error('Failed to create tag:', result.error); 90 + } 91 + }; 65 92 66 - // Listen for things that'll change state 67 - initEventListeners(); 93 + /** 94 + * Show the groups (tags) view 95 + */ 96 + const showGroups = async () => { 97 + state.view = VIEW_GROUPS; 98 + state.currentTag = null; 68 99 69 - // Populate groups with their pages 70 - showCards(); 71 - }; 100 + // Refresh tags 101 + await loadTags(); 72 102 73 - const initStorage = () => { 74 - let data = localStorage.getItem(EXT_DATA_CONFIG_KEY); 103 + // Update UI 104 + document.querySelector('.header-title').textContent = 'Groups'; 105 + document.querySelector('.back-btn').style.display = 'none'; 106 + document.querySelector('.new-group-btn').style.display = 'block'; 75 107 76 - console.log('initStorage', data); 108 + // Clear and populate cards 109 + const container = document.querySelector('.cards'); 110 + container.innerHTML = ''; 77 111 78 - // Not first run! 79 - if (data && data.config) { 80 - config = data.config; 112 + if (state.tags.length === 0) { 113 + container.innerHTML = '<div class="empty-state">No groups yet. Create one to get started.</div>'; 114 + return; 81 115 } 82 - else { 83 - // First run! 84 116 85 - config = {}; 117 + state.tags.forEach(tag => { 118 + const card = createGroupCard(tag); 119 + container.appendChild(card); 120 + }); 121 + }; 86 122 87 - // Set up group storage 88 - config.groups = {}; 123 + /** 124 + * Show addresses in a group (tag) 125 + */ 126 + const showAddresses = async (tag) => { 127 + state.view = VIEW_ADDRESSES; 128 + state.currentTag = tag; 89 129 90 - // Create default group 91 - const id = newGroup(DEFAULT_GROUP_TITLE); 130 + // Load addresses for this tag 131 + await loadAddressesForTag(tag.id); 92 132 93 - // Storage default group id as last active group 94 - config.lastGroupId = id; 133 + // Update UI 134 + document.querySelector('.header-title').textContent = tag.name; 135 + document.querySelector('.back-btn').style.display = 'block'; 136 + document.querySelector('.new-group-btn').style.display = 'none'; 95 137 96 - // save on first run 97 - updateStorage(EXT_DATA_CONFIG_KEY, config); 138 + // Clear and populate cards 139 + const container = document.querySelector('.cards'); 140 + container.innerHTML = ''; 141 + 142 + if (state.addresses.length === 0) { 143 + container.innerHTML = '<div class="empty-state">No addresses in this group yet.</div>'; 144 + return; 98 145 } 99 146 100 - return config; 101 - } 147 + state.addresses.forEach(address => { 148 + const card = createAddressCard(address); 149 + container.appendChild(card); 150 + }); 151 + }; 102 152 103 - // Save changes to config to persistent storage 104 - // TODO: Temporary hack. Replace with proper evented solution. 105 - function updateStorage(key, data) { 106 - localStorage.setItem(key, data); 107 - } 153 + /** 154 + * Create a card element for a group (tag) 155 + */ 156 + const createGroupCard = (tag) => { 157 + const card = document.createElement('div'); 158 + card.className = 'card group-card'; 159 + card.dataset.tagId = tag.id; 108 160 109 - function initEventListeners() { 110 - /* 111 - // add new tabs in this window to current group 112 - browser.tabs.onCreated.addListener(tab => { 113 - addCardToGroup(tab.id, config.lastGroupId); 114 - }); 161 + const colorDot = document.createElement('div'); 162 + colorDot.className = 'color-dot'; 163 + colorDot.style.backgroundColor = tag.color || '#999'; 115 164 116 - // remove detached tabs from their group 117 - browser.tabs.onDetached.addListener(tab => { 118 - removeCardFromGroup(tab.id, config.lastGroupId); 119 - }); 120 - 121 - // remove removed tabs from their group 122 - browser.tabs.onRemoved.addListener(tab => { 123 - removeCardFromGroup(tab.id, config.lastGroupId); 124 - }); 125 - */ 126 - } 165 + const content = document.createElement('div'); 166 + content.className = 'card-content'; 127 167 128 - function addCardToGroup(pageId, groupId) { 129 - const index = config.groups[groupId].pages.indexOf(pageId); 130 - if (index == -1) { 131 - config.groups[groupId].pages.push(pageId); 132 - } 133 - } 168 + const title = document.createElement('h2'); 169 + title.className = 'card-title'; 170 + title.textContent = tag.name; 134 171 135 - function removeCardFromGroup(pageId, groupId) { 136 - var index = config.groups[groupId].tabs.indexOf(pageId); 137 - if (index > -1) { 138 - config.groups[groupId].tabs.splice(index, 1); 139 - } 140 - } 172 + const meta = document.createElement('div'); 173 + meta.className = 'card-meta'; 174 + meta.textContent = `Used ${tag.frequency || 0} times`; 141 175 142 - function getGroupById(id) { 143 - return config.groups[id]; 144 - } 176 + content.appendChild(title); 177 + content.appendChild(meta); 145 178 146 - function activateGroup(groupId) { 147 - var group = getGroupById(groupId); 148 - var lastGroup = getGroupById(config.lastGroupId); 149 - if (group.tabs.length === 0) { 150 - // New group? 151 - browser.tabs.create({}).then(tab => { 152 - addCardToGroup(tab.id, groupId); 153 - browser.tabshideshow.show(group.tabs); 154 - browser.tabshideshow.hide(lastGroup.tabs); 155 - setLastActiveGroupId(groupId); 156 - }); 157 - } 158 - else { 159 - browser.tabshideshow.show(group.tabs); 160 - browser.tabshideshow.hide(lastGroup.tabs); 161 - setLastActiveGroupId(groupId); 162 - } 163 - } 179 + card.appendChild(colorDot); 180 + card.appendChild(content); 164 181 165 - function getLastActiveGroupId() { 166 - return config.lastGroupId; 167 - } 182 + // Click to view addresses in this group 183 + card.addEventListener('click', () => showAddresses(tag)); 168 184 169 - function setLastActiveGroupId(id) { 170 - config.lastGroupId = id; 171 - } 185 + return card; 186 + }; 172 187 173 - function initializeGroupData() { 174 - /* 175 - return new Promise(function(resolve, reject) { 176 - // Clear out old tab ids 177 - for (let id in config.groups) { 178 - config.groups[id].tabs = []; 179 - } 180 - 181 - browser.tabs.query({currentWindow: true}).then(tabs => { 182 - tabs.forEach(tab => { 183 - browser.sessions.getCardValue(tab.id, TAB_DATA_GROUP_KEY).then(groupId => { 184 - if (groupId && config.groups[groupId]) { 185 - config.groups[groupId].tabs.push(tab.id); 186 - } 187 - else { 188 - // This should only happen on first run. 189 - // Add all default tabs to default group. 190 - var groupId = getLastActiveGroupId(); 191 - addCardToGroup(tab.id, groupId); 192 - } 193 - }); 194 - }); 195 - resolve(); 196 - }); 197 - }); 198 - */ 199 - } 188 + /** 189 + * Create a card element for an address 190 + */ 191 + const createAddressCard = (address) => { 192 + const card = document.createElement('div'); 193 + card.className = 'card address-card'; 194 + card.dataset.addressId = address.id; 200 195 201 - function newGroup(title) { 202 - var id = window.crypto.getRandomValues(new Uint32Array(1))[0]; 203 - var group = { 204 - id: id, 205 - title: title || 'New Group', 206 - tabs: [] 196 + const favicon = document.createElement('img'); 197 + favicon.className = 'card-favicon'; 198 + favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 199 + favicon.onerror = () => { 200 + favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 207 201 }; 208 - config.groups[id] = group; 209 - showGroups(); 210 - updateStorage(); 211 - return id; 212 - } 213 202 214 - function showGroups() { 215 - clearCards(); 216 - for (let id in config.groups) { 217 - var group = config.groups[id]; 218 - var card = addCard(); 219 - card.querySelector('h1').innerText = group.title; 220 - card.dataset.id = group.id; 221 - card.addEventListener('click', e => { 222 - var groupId = parseInt(card.dataset.id); 223 - if (groupId != config.lastGroupId) { 224 - activateGroup(groupId); 225 - } 226 - }); 227 - card.classList.add('group'); 228 - } 229 - currentView = VIEW_GROUPS; 230 - document.querySelector('.controls').classList.add('groupsview'); 231 - document.querySelector('.controls').classList.remove('tabsview'); 232 - } 203 + const content = document.createElement('div'); 204 + content.className = 'card-content'; 233 205 234 - function showCards() { 235 - var group = getGroupById(config.lastGroupId); 236 - clearCards(); 206 + const title = document.createElement('h2'); 207 + title.className = 'card-title'; 208 + title.textContent = address.title || address.uri; 237 209 238 - group.pages.forEach(page => { 239 - var card = addCard(); 240 - card.querySelector('h1').innerText = page.title; 241 - card.dataset.id = page.id; 242 - card.addEventListener('click', e => { 243 - var pageId = parseInt(card.dataset.id); 244 - browser.pages.update(pageId, { active: true }); 245 - }); 246 - }); 210 + const url = document.createElement('div'); 211 + url.className = 'card-url'; 212 + url.textContent = address.uri; 247 213 248 - currentView = VIEW_PAGES; 249 - document.querySelector('.controls').classList.add('pagesview'); 250 - document.querySelector('.controls').classList.remove('groupsview'); 251 - } 214 + const meta = document.createElement('div'); 215 + meta.className = 'card-meta'; 216 + const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 217 + meta.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 252 218 253 - function clearCards() { 254 - var container = document.querySelector('.cards'); 255 - Array.prototype.slice.call(container.children).forEach(child => { 256 - child.parentNode.removeChild(child); 257 - }); 258 - } 219 + content.appendChild(title); 220 + content.appendChild(url); 221 + content.appendChild(meta); 259 222 260 - function addCard() { 261 - var container = document.querySelector('.cards'); 223 + card.appendChild(favicon); 224 + card.appendChild(content); 262 225 263 - var cardTpl = document.querySelector('.tpl-card') 264 - var cardClone = document.importNode(cardTpl.content, true); 265 - container.appendChild(cardClone); 266 - var card = container.lastElementChild; 267 - card.classList.add('card'); 226 + // Click to open address 227 + card.addEventListener('click', async () => { 228 + debug && console.log('Opening address:', address.uri); 229 + const result = await api.window.open(address.uri, { 230 + width: 800, 231 + height: 600 232 + }); 233 + debug && console.log('Window opened:', result); 234 + }); 268 235 269 236 return card; 270 - } 237 + }; 271 238 272 - // Kick out the jams 239 + // Initialize when DOM is ready 273 240 document.addEventListener('DOMContentLoaded', init); 274 - 275 - 276 - })();
+264 -12
index.js
··· 1476 1476 } 1477 1477 }); 1478 1478 1479 + // ***** Tag IPC Handlers ***** 1480 + 1481 + // Calculate frecency score: frequency * 10 * decay_factor 1482 + // decay_factor = 1 / (1 + days_since_use / 7) 1483 + const calculateFrecency = (frequency, lastUsedAt) => { 1484 + const currentTime = Date.now(); 1485 + const daysSinceUse = (currentTime - lastUsedAt) / (1000 * 60 * 60 * 24); 1486 + const decayFactor = 1 / (1 + daysSinceUse / 7); 1487 + return frequency * 10 * decayFactor; 1488 + }; 1489 + 1490 + // Get or create a tag by name 1491 + ipcMain.handle('datastore-get-or-create-tag', async (ev, data) => { 1492 + try { 1493 + const { name } = data; 1494 + const slug = name.toLowerCase().trim().replace(/\s+/g, '-'); 1495 + const timestamp = now(); 1496 + 1497 + // Look for existing tag by name 1498 + const tagsTable = datastoreStore.getTable('tags'); 1499 + let existingTag = null; 1500 + let existingTagId = null; 1501 + 1502 + for (const [id, tag] of Object.entries(tagsTable)) { 1503 + if (tag.name.toLowerCase() === name.toLowerCase()) { 1504 + existingTag = tag; 1505 + existingTagId = id; 1506 + break; 1507 + } 1508 + } 1509 + 1510 + if (existingTag) { 1511 + return { success: true, data: { id: existingTagId, ...existingTag }, created: false }; 1512 + } 1513 + 1514 + // Create new tag 1515 + const tagId = generateId('tag'); 1516 + const newTag = { 1517 + name: name.trim(), 1518 + slug, 1519 + color: '#999999', 1520 + parentId: '', 1521 + description: '', 1522 + metadata: '{}', 1523 + createdAt: timestamp, 1524 + updatedAt: timestamp, 1525 + frequency: 0, 1526 + lastUsedAt: 0, 1527 + frecencyScore: 0 1528 + }; 1529 + 1530 + datastoreStore.setRow('tags', tagId, newTag); 1531 + return { success: true, data: { id: tagId, ...newTag }, created: true }; 1532 + } catch (error) { 1533 + console.error('datastore-get-or-create-tag error:', error); 1534 + return { success: false, error: error.message }; 1535 + } 1536 + }); 1537 + 1538 + // Tag an address (create the link and update frecency) 1539 + ipcMain.handle('datastore-tag-address', async (ev, data) => { 1540 + try { 1541 + const { addressId, tagId } = data; 1542 + const timestamp = now(); 1543 + 1544 + // Check if link already exists 1545 + const addressTagsTable = datastoreStore.getTable('address_tags'); 1546 + for (const [id, link] of Object.entries(addressTagsTable)) { 1547 + if (link.addressId === addressId && link.tagId === tagId) { 1548 + return { success: true, data: { id, ...link }, alreadyExists: true }; 1549 + } 1550 + } 1551 + 1552 + // Create the link 1553 + const linkId = generateId('address_tag'); 1554 + const newLink = { 1555 + addressId, 1556 + tagId, 1557 + createdAt: timestamp 1558 + }; 1559 + datastoreStore.setRow('address_tags', linkId, newLink); 1560 + 1561 + // Update tag frequency and frecency 1562 + const tag = datastoreStore.getRow('tags', tagId); 1563 + if (tag) { 1564 + const newFrequency = (tag.frequency || 0) + 1; 1565 + const frecencyScore = calculateFrecency(newFrequency, timestamp); 1566 + datastoreStore.setRow('tags', tagId, { 1567 + ...tag, 1568 + frequency: newFrequency, 1569 + lastUsedAt: timestamp, 1570 + frecencyScore, 1571 + updatedAt: timestamp 1572 + }); 1573 + } 1574 + 1575 + return { success: true, data: { id: linkId, ...newLink } }; 1576 + } catch (error) { 1577 + console.error('datastore-tag-address error:', error); 1578 + return { success: false, error: error.message }; 1579 + } 1580 + }); 1581 + 1582 + // Untag an address (remove the link) 1583 + ipcMain.handle('datastore-untag-address', async (ev, data) => { 1584 + try { 1585 + const { addressId, tagId } = data; 1586 + 1587 + // Find and remove the link 1588 + const addressTagsTable = datastoreStore.getTable('address_tags'); 1589 + for (const [id, link] of Object.entries(addressTagsTable)) { 1590 + if (link.addressId === addressId && link.tagId === tagId) { 1591 + datastoreStore.delRow('address_tags', id); 1592 + return { success: true, removed: true }; 1593 + } 1594 + } 1595 + 1596 + return { success: true, removed: false }; 1597 + } catch (error) { 1598 + console.error('datastore-untag-address error:', error); 1599 + return { success: false, error: error.message }; 1600 + } 1601 + }); 1602 + 1603 + // Get all tags sorted by frecency 1604 + ipcMain.handle('datastore-get-tags-by-frecency', async (ev, data = {}) => { 1605 + try { 1606 + const { domain } = data || {}; 1607 + const tagsTable = datastoreStore.getTable('tags'); 1608 + let tags = Object.entries(tagsTable).map(([id, tag]) => ({ id, ...tag })); 1609 + 1610 + // Recalculate frecency scores (they decay over time) 1611 + tags = tags.map(tag => ({ 1612 + ...tag, 1613 + frecencyScore: calculateFrecency(tag.frequency || 0, tag.lastUsedAt || 0) 1614 + })); 1615 + 1616 + // If domain provided, boost tags used on same-domain addresses 1617 + if (domain) { 1618 + const addressesTable = datastoreStore.getTable('addresses'); 1619 + const addressTagsTable = datastoreStore.getTable('address_tags'); 1620 + 1621 + // Find addresses with matching domain 1622 + const domainAddressIds = new Set(); 1623 + for (const [id, addr] of Object.entries(addressesTable)) { 1624 + if (addr.domain === domain) { 1625 + domainAddressIds.add(id); 1626 + } 1627 + } 1628 + 1629 + // Find tags used on those addresses 1630 + const domainTagIds = new Set(); 1631 + for (const [, link] of Object.entries(addressTagsTable)) { 1632 + if (domainAddressIds.has(link.addressId)) { 1633 + domainTagIds.add(link.tagId); 1634 + } 1635 + } 1636 + 1637 + // Apply 2x boost 1638 + tags = tags.map(tag => ({ 1639 + ...tag, 1640 + frecencyScore: domainTagIds.has(tag.id) ? tag.frecencyScore * 2 : tag.frecencyScore 1641 + })); 1642 + } 1643 + 1644 + // Sort by frecency descending 1645 + tags.sort((a, b) => b.frecencyScore - a.frecencyScore); 1646 + 1647 + return { success: true, data: tags }; 1648 + } catch (error) { 1649 + console.error('datastore-get-tags-by-frecency error:', error); 1650 + return { success: false, error: error.message }; 1651 + } 1652 + }); 1653 + 1654 + // Get tags for a specific address 1655 + ipcMain.handle('datastore-get-address-tags', async (ev, data) => { 1656 + try { 1657 + const { addressId } = data; 1658 + const addressTagsTable = datastoreStore.getTable('address_tags'); 1659 + const tagsTable = datastoreStore.getTable('tags'); 1660 + 1661 + const tagIds = []; 1662 + for (const [, link] of Object.entries(addressTagsTable)) { 1663 + if (link.addressId === addressId) { 1664 + tagIds.push(link.tagId); 1665 + } 1666 + } 1667 + 1668 + const tags = tagIds 1669 + .map(tagId => { 1670 + const tag = tagsTable[tagId]; 1671 + return tag ? { id: tagId, ...tag } : null; 1672 + }) 1673 + .filter(Boolean); 1674 + 1675 + return { success: true, data: tags }; 1676 + } catch (error) { 1677 + console.error('datastore-get-address-tags error:', error); 1678 + return { success: false, error: error.message }; 1679 + } 1680 + }); 1681 + 1682 + // Get addresses with a specific tag 1683 + ipcMain.handle('datastore-get-addresses-by-tag', async (ev, data) => { 1684 + try { 1685 + const { tagId } = data; 1686 + const addressTagsTable = datastoreStore.getTable('address_tags'); 1687 + const addressesTable = datastoreStore.getTable('addresses'); 1688 + 1689 + const addressIds = []; 1690 + for (const [, link] of Object.entries(addressTagsTable)) { 1691 + if (link.tagId === tagId) { 1692 + addressIds.push(link.addressId); 1693 + } 1694 + } 1695 + 1696 + const addresses = addressIds 1697 + .map(addressId => { 1698 + const addr = addressesTable[addressId]; 1699 + return addr ? { id: addressId, ...addr } : null; 1700 + }) 1701 + .filter(Boolean); 1702 + 1703 + return { success: true, data: addresses }; 1704 + } catch (error) { 1705 + console.error('datastore-get-addresses-by-tag error:', error); 1706 + return { success: false, error: error.message }; 1707 + } 1708 + }); 1709 + 1479 1710 const modWindow = (bw, params) => { 1480 1711 if (params.action == 'close') { 1481 1712 bw.close(); ··· 1621 1852 } 1622 1853 }; 1623 1854 1855 + // Only hide the app if there are no other visible windows (besides the one being closed/hidden) 1856 + const maybeHideApp = (excludeId) => { 1857 + if (process.platform !== 'darwin') return; 1858 + 1859 + // Check if there are any other visible windows 1860 + const visibleWindows = BrowserWindow.getAllWindows().filter(win => { 1861 + if (win.id === excludeId) return false; 1862 + if (win.isDestroyed()) return false; 1863 + if (!win.isVisible()) return false; 1864 + 1865 + // Exclude the background window 1866 + const entry = windowManager.getWindow(win.id); 1867 + if (entry && entry.params.address === webCoreAddress) return false; 1868 + 1869 + return true; 1870 + }); 1871 + 1872 + console.log('maybeHideApp: visible windows (excluding', excludeId + '):', visibleWindows.length); 1873 + 1874 + if (visibleWindows.length === 0) { 1875 + console.log('No other visible windows, hiding app'); 1876 + app.hide(); 1877 + } else { 1878 + console.log('Other windows visible, not hiding app'); 1879 + } 1880 + }; 1881 + 1624 1882 const closeOrHideWindow = id => { 1625 1883 console.log('closeOrHideWindow called for ID:', id); 1626 1884 ··· 1654 1912 console.log(`CLOSING settings window ${id}`); 1655 1913 closeChildWindows(params.address); 1656 1914 win.close(); 1657 - // Hide app to return focus to previous app 1658 - if (process.platform === 'darwin') { 1659 - app.hide(); 1660 - } 1915 + // Hide app to return focus to previous app (only if no other visible windows) 1916 + maybeHideApp(id); 1661 1917 } 1662 1918 // Check if window should be hidden rather than closed 1663 1919 // Either keepLive or modal parameter can trigger hiding behavior 1664 1920 else if (params.keepLive === true || params.modal === true) { 1665 1921 //console.log(`HIDING window ${id} (${params.address}) - modal: ${params.modal}, keepLive: ${params.keepLive}`); 1666 1922 win.hide(); 1667 - // Hide app to return focus to previous app 1668 - if (process.platform === 'darwin') { 1669 - app.hide(); 1670 - } 1923 + // Hide app to return focus to previous app (only if no other visible windows) 1924 + maybeHideApp(id); 1671 1925 } else { 1672 1926 // close any open windows this window opened 1673 1927 closeChildWindows(params.address); 1674 1928 console.log(`CLOSING window ${id} (${params.address})`); 1675 1929 win.close(); 1676 - // Hide app to return focus to previous app 1677 - if (process.platform === 'darwin') { 1678 - app.hide(); 1679 - } 1930 + // Hide app to return focus to previous app (only if no other visible windows) 1931 + maybeHideApp(id); 1680 1932 } 1681 1933 1682 1934 console.log('closeOrHideWindow completed');
+19
preload.js
··· 248 248 }, 249 249 getStats: () => { 250 250 return ipcRenderer.invoke('datastore-get-stats'); 251 + }, 252 + // Tag operations 253 + getOrCreateTag: (name) => { 254 + return ipcRenderer.invoke('datastore-get-or-create-tag', { name }); 255 + }, 256 + tagAddress: (addressId, tagId) => { 257 + return ipcRenderer.invoke('datastore-tag-address', { addressId, tagId }); 258 + }, 259 + untagAddress: (addressId, tagId) => { 260 + return ipcRenderer.invoke('datastore-untag-address', { addressId, tagId }); 261 + }, 262 + getTagsByFrecency: (domain) => { 263 + return ipcRenderer.invoke('datastore-get-tags-by-frecency', { domain }); 264 + }, 265 + getAddressTags: (addressId) => { 266 + return ipcRenderer.invoke('datastore-get-address-tags', { addressId }); 267 + }, 268 + getAddressesByTag: (tagId) => { 269 + return ipcRenderer.invoke('datastore-get-addresses-by-tag', { tagId }); 251 270 } 252 271 }; 253 272