experiments in a post-browser web
10
fork

Configure Feed

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

refactor(extensions): create files extension with csv and save commands

+964 -427
-122
extensions/cmd/commands/csv.js
··· 1 - /** 2 - * CSV command - convert JSON data to CSV format 3 - * 4 - * This is a chaining-enabled command that accepts JSON input 5 - * and produces CSV output. 6 - * 7 - * Usage in chain: 8 - * lists → csv → save 9 - */ 10 - 11 - /** 12 - * Convert JSON array to CSV string 13 - */ 14 - function jsonToCsv(data) { 15 - if (!Array.isArray(data) || data.length === 0) { 16 - return ''; 17 - } 18 - 19 - // Get all unique keys from all objects 20 - const keys = new Set(); 21 - data.forEach(item => { 22 - if (typeof item === 'object' && item !== null) { 23 - Object.keys(item).forEach(key => keys.add(key)); 24 - } 25 - }); 26 - 27 - const headers = Array.from(keys); 28 - 29 - // Escape CSV value 30 - const escapeValue = (val) => { 31 - if (val === null || val === undefined) return ''; 32 - const str = String(val); 33 - // If contains comma, newline, or quote, wrap in quotes and escape existing quotes 34 - if (str.includes(',') || str.includes('\n') || str.includes('"')) { 35 - return '"' + str.replace(/"/g, '""') + '"'; 36 - } 37 - return str; 38 - }; 39 - 40 - // Build CSV 41 - const lines = []; 42 - 43 - // Header row 44 - lines.push(headers.map(escapeValue).join(',')); 45 - 46 - // Data rows 47 - data.forEach(item => { 48 - if (typeof item === 'object' && item !== null) { 49 - const row = headers.map(header => escapeValue(item[header])); 50 - lines.push(row.join(',')); 51 - } else { 52 - // Simple value, put in first column 53 - lines.push(escapeValue(item)); 54 - } 55 - }); 56 - 57 - return lines.join('\n'); 58 - } 59 - 60 - export default { 61 - name: 'csv', 62 - description: 'Convert JSON to CSV format', 63 - accepts: ['application/json'], 64 - produces: ['text/csv'], 65 - 66 - execute: async (ctx) => { 67 - console.log('[csv] execute:', ctx); 68 - 69 - // Check if we have input data from chain 70 - if (!ctx.input) { 71 - console.log('[csv] No input data'); 72 - return { 73 - success: false, 74 - error: 'No input data. Use this command in a chain after a command that produces JSON.' 75 - }; 76 - } 77 - 78 - try { 79 - // Parse input if it's a string 80 - let data = ctx.input; 81 - if (typeof data === 'string') { 82 - try { 83 - data = JSON.parse(data); 84 - } catch (e) { 85 - // Not JSON, treat as plain text 86 - return { 87 - success: false, 88 - error: 'Input is not valid JSON' 89 - }; 90 - } 91 - } 92 - 93 - // Ensure we have an array 94 - if (!Array.isArray(data)) { 95 - // If it's an object with an 'items' property, use that 96 - if (data && Array.isArray(data.items)) { 97 - data = data.items; 98 - } else { 99 - // Wrap single object in array 100 - data = [data]; 101 - } 102 - } 103 - 104 - // Convert to CSV 105 - const csvOutput = jsonToCsv(data); 106 - 107 - console.log('[csv] Converted', data.length, 'items to CSV'); 108 - 109 - return { 110 - success: true, 111 - output: { 112 - data: csvOutput, 113 - mimeType: 'text/csv', 114 - title: `CSV (${data.length} rows)` 115 - } 116 - }; 117 - } catch (err) { 118 - console.error('[csv] Error:', err); 119 - return { success: false, error: err.message }; 120 - } 121 - } 122 - };
+2 -4
extensions/cmd/commands/index.js
··· 2 2 * Commands module - exports all available commands 3 3 * Note: groups commands are now provided by the groups extension 4 4 * Note: sync command is now provided by the sync extension 5 + * Note: open/modal commands are now provided by the page extension 5 6 */ 6 - import openCommand from './open.js'; 7 7 import debugCommand from './debug.js'; 8 - import modalCommand from './modal.js'; 9 8 import noteModule from './note.js'; 10 9 import tagsetModule from './tagset.js'; 11 10 import urlModule from './url.js'; ··· 33 32 // Active commands - only these will be loaded 34 33 // Note: groups commands are dynamically registered by the groups extension 35 34 // Note: sync command is dynamically registered by the sync extension 35 + // Note: open/modal commands are dynamically registered by the page extension 36 36 const activeCommands = [ 37 - openCommand, 38 37 debugCommand, 39 - modalCommand, 40 38 ...noteModule.commands, 41 39 ...tagsetModule.commands, 42 40 ...urlModule.commands,
-36
extensions/cmd/commands/modal.js
··· 1 - /** 2 - * Modal command - opens a URL in a modal window that hides on blur or escape 3 - */ 4 - import windows from 'peek://app/windows.js'; 5 - 6 - export default { 7 - name: 'modal', 8 - execute: async (msg) => { 9 - console.log('modal command', msg); 10 - 11 - const parts = msg.typed.split(' '); 12 - parts.shift(); 13 - 14 - const address = parts.shift(); 15 - 16 - if (!address) { 17 - return; 18 - } 19 - 20 - // Use the modal window API 21 - try { 22 - const result = await windows.openModalWindow(address, { 23 - width: 700, 24 - height: 500 25 - }); 26 - console.log('Modal window opened:', result); 27 - } catch (error) { 28 - console.error('Failed to open modal window:', error); 29 - } 30 - 31 - return { 32 - command: 'modal', 33 - address 34 - }; 35 - } 36 - };
-98
extensions/cmd/commands/open.js
··· 1 - /** 2 - * Open command - opens a URL in a new window 3 - * Only opens window if input is a valid URL 4 - */ 5 - import windows from 'peek://app/windows.js'; 6 - 7 - export default { 8 - name: 'open', 9 - execute: async (msg) => { 10 - console.log('open command', msg); 11 - 12 - const parts = msg.typed.split(' '); 13 - parts.shift(); 14 - 15 - const address = parts.shift(); 16 - 17 - if (!address) { 18 - console.log('No address provided'); 19 - return { error: 'No address provided' }; 20 - } 21 - 22 - // Check if the input is a valid URL and get the normalized version 23 - const urlResult = getValidURL(address); 24 - if (!urlResult.valid) { 25 - console.log('Invalid URL:', address); 26 - return { error: 'Invalid URL. Must be a valid URL starting with http://, https://, or other valid protocol.' }; 27 - } 28 - 29 - // Use the normalized URL (with protocol added if needed) 30 - const normalizedAddress = urlResult.url; 31 - console.log('Using normalized URL:', normalizedAddress); 32 - 33 - // Use the new windows API (tracking handled automatically) 34 - try { 35 - const windowController = await windows.createWindow(normalizedAddress, { 36 - width: 800, 37 - height: 600, 38 - openDevTools: window.app.debug, 39 - trackingSource: 'cmd', 40 - trackingSourceId: 'open' 41 - }); 42 - console.log('Window opened with ID:', windowController.id); 43 - 44 - return { 45 - command: 'open', 46 - address: normalizedAddress, 47 - success: true 48 - }; 49 - } catch (error) { 50 - console.error('Failed to open window:', error); 51 - return { 52 - error: 'Failed to open window: ' + error.message, 53 - address: normalizedAddress 54 - }; 55 - } 56 - } 57 - }; 58 - 59 - /** 60 - * Validates and normalizes a URL string 61 - * @param {string} str - The string to check 62 - * @returns {Object} - Object with valid flag and normalized URL 63 - */ 64 - function getValidURL(str) { 65 - // Quick check for empty string 66 - if (!str) return { valid: false }; 67 - 68 - // Check if it starts with a valid protocol 69 - const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str); 70 - 71 - if (!hasValidProtocol) { 72 - // Check if it looks like a domain (e.g., "example.com", "example.com/path", "localhost") 73 - // Pattern: domain.tld or domain.tld/path (with optional port for localhost) 74 - const isDomainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(str); 75 - const isLocalhost = /^localhost(:\d+)?(\/.*)?$/.test(str); 76 - 77 - if (isDomainPattern || isLocalhost) { 78 - // It's a domain without protocol, add https:// 79 - const urlWithProtocol = 'https://' + str; 80 - try { 81 - // Validate the URL with added protocol 82 - new URL(urlWithProtocol); 83 - return { valid: true, url: urlWithProtocol }; 84 - } catch (e) { 85 - return { valid: false }; 86 - } 87 - } 88 - return { valid: false }; 89 - } 90 - 91 - try { 92 - // Already has protocol, just validate 93 - new URL(str); 94 - return { valid: true, url: str }; 95 - } catch (e) { 96 - return { valid: false }; 97 - } 98 - }
-107
extensions/cmd/commands/save.js
··· 1 - /** 2 - * Save command - save data to a file 3 - * 4 - * This is a chaining-enabled command that accepts any input 5 - * and saves it as a file download. 6 - * 7 - * Usage in chain: 8 - * lists → csv → save myfile.csv 9 - * lists → save data.json 10 - */ 11 - 12 - const api = window.app; 13 - 14 - /** 15 - * Get file extension from MIME type 16 - */ 17 - function getExtensionFromMime(mimeType) { 18 - const mimeToExt = { 19 - 'application/json': 'json', 20 - 'text/csv': 'csv', 21 - 'text/plain': 'txt', 22 - 'text/html': 'html', 23 - 'application/xml': 'xml', 24 - 'text/xml': 'xml' 25 - }; 26 - return mimeToExt[mimeType] || 'txt'; 27 - } 28 - 29 - /** 30 - * Generate default filename 31 - */ 32 - function generateFilename(mimeType, title) { 33 - const ext = getExtensionFromMime(mimeType); 34 - const timestamp = new Date().toISOString().slice(0, 10); 35 - 36 - if (title) { 37 - // Sanitize title for filename 38 - const safeTitle = title.toLowerCase() 39 - .replace(/[^a-z0-9]+/g, '-') 40 - .replace(/^-|-$/g, '') 41 - .substring(0, 30); 42 - return `${safeTitle}-${timestamp}.${ext}`; 43 - } 44 - 45 - return `export-${timestamp}.${ext}`; 46 - } 47 - 48 - export default { 49 - name: 'save', 50 - description: 'Save data to a file', 51 - accepts: ['*/*'], // Accept any MIME type 52 - produces: [], // End of chain - doesn't produce output 53 - 54 - execute: async (ctx) => { 55 - console.log('[save] execute:', ctx); 56 - 57 - // Check if we have input data from chain 58 - if (!ctx.input) { 59 - console.log('[save] No input data'); 60 - return { 61 - success: false, 62 - error: 'No input data. Use this command in a chain after a command that produces output.' 63 - }; 64 - } 65 - 66 - try { 67 - const data = ctx.input; 68 - const mimeType = ctx.inputMimeType || 'text/plain'; 69 - 70 - // Determine filename - use search arg if provided, otherwise generate 71 - let filename = ctx.search?.trim(); 72 - if (!filename) { 73 - filename = generateFilename(mimeType, ctx.inputTitle); 74 - } 75 - 76 - // Ensure proper extension 77 - if (!filename.includes('.')) { 78 - filename += '.' + getExtensionFromMime(mimeType); 79 - } 80 - 81 - // Stringify data if needed 82 - let content; 83 - if (typeof data === 'string') { 84 - content = data; 85 - } else { 86 - content = JSON.stringify(data, null, 2); 87 - } 88 - 89 - // Send to background script to handle download 90 - // Background persists regardless of panel state 91 - api.publish('cmd:save-file', { 92 - content, 93 - filename, 94 - mimeType 95 - }, api.scopes.GLOBAL); 96 - 97 - console.log('[save] Requested download:', filename); 98 - return { 99 - success: true, 100 - message: `Saving ${filename}...` 101 - }; 102 - } catch (err) { 103 - console.error('[save] Error:', err); 104 - return { success: false, error: err.message }; 105 - } 106 - } 107 - };
-60
extensions/cmd/commands/sync.js
··· 1 - /** 2 - * Sync command - manually trigger a full bidirectional sync 3 - */ 4 - 5 - const api = window.app; 6 - 7 - export default { 8 - name: 'Sync now', 9 - description: 'Trigger manual sync with server', 10 - 11 - execute: async () => { 12 - console.log('[sync] Triggering manual sync...'); 13 - 14 - // Check if sync API is available 15 - if (!api?.sync?.syncAll) { 16 - console.error('[sync] Sync API not available'); 17 - return { 18 - success: false, 19 - error: 'Sync API not available' 20 - }; 21 - } 22 - 23 - try { 24 - const result = await api.sync.syncAll(); 25 - 26 - if (result.success) { 27 - const { pulled, pushed, conflicts } = result.data || {}; 28 - console.log('[sync] Sync completed:', result.data); 29 - 30 - let message = 'Sync completed'; 31 - const details = []; 32 - if (pulled) details.push(`${pulled} pulled`); 33 - if (pushed) details.push(`${pushed} pushed`); 34 - if (conflicts) details.push(`${conflicts} conflicts`); 35 - if (details.length) message += `: ${details.join(', ')}`; 36 - 37 - return { 38 - success: true, 39 - command: 'sync', 40 - message, 41 - data: result.data 42 - }; 43 - } else { 44 - console.error('[sync] Sync failed:', result.error); 45 - return { 46 - success: false, 47 - command: 'sync', 48 - error: result.error || 'Sync failed' 49 - }; 50 - } 51 - } catch (err) { 52 - console.error('[sync] Error during sync:', err); 53 - return { 54 - success: false, 55 - command: 'sync', 56 - error: err.message 57 - }; 58 - } 59 - } 60 - };
+50
extensions/files/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Files Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+274
extensions/files/background.js
··· 1 + /** 2 + * Files Extension Background Script 3 + * 4 + * File and format commands (CSV, save) 5 + * 6 + * Runs in isolated extension process (peek://ext/files/background.html) 7 + */ 8 + 9 + const api = window.app; 10 + const debug = api.debug; 11 + 12 + const id = 'files'; 13 + const labels = { name: 'Files' }; 14 + 15 + console.log('[ext:files] background', labels.name); 16 + 17 + // ===== CSV Command ===== 18 + 19 + /** 20 + * Convert JSON array to CSV string 21 + */ 22 + function jsonToCsv(data) { 23 + if (!Array.isArray(data) || data.length === 0) { 24 + return ''; 25 + } 26 + 27 + // Get all unique keys from all objects 28 + const keys = new Set(); 29 + data.forEach(item => { 30 + if (typeof item === 'object' && item !== null) { 31 + Object.keys(item).forEach(key => keys.add(key)); 32 + } 33 + }); 34 + 35 + const headers = Array.from(keys); 36 + 37 + // Escape CSV value 38 + const escapeValue = (val) => { 39 + if (val === null || val === undefined) return ''; 40 + const str = String(val); 41 + // If contains comma, newline, or quote, wrap in quotes and escape existing quotes 42 + if (str.includes(',') || str.includes('\n') || str.includes('"')) { 43 + return '"' + str.replace(/"/g, '""') + '"'; 44 + } 45 + return str; 46 + }; 47 + 48 + // Build CSV 49 + const lines = []; 50 + 51 + // Header row 52 + lines.push(headers.map(escapeValue).join(',')); 53 + 54 + // Data rows 55 + data.forEach(item => { 56 + if (typeof item === 'object' && item !== null) { 57 + const row = headers.map(header => escapeValue(item[header])); 58 + lines.push(row.join(',')); 59 + } else { 60 + // Simple value, put in first column 61 + lines.push(escapeValue(item)); 62 + } 63 + }); 64 + 65 + return lines.join('\n'); 66 + } 67 + 68 + // ===== Save Command ===== 69 + 70 + /** 71 + * Get file extension from MIME type 72 + */ 73 + function getExtensionFromMime(mimeType) { 74 + const mimeToExt = { 75 + 'application/json': 'json', 76 + 'text/csv': 'csv', 77 + 'text/plain': 'txt', 78 + 'text/html': 'html', 79 + 'application/xml': 'xml', 80 + 'text/xml': 'xml' 81 + }; 82 + return mimeToExt[mimeType] || 'txt'; 83 + } 84 + 85 + /** 86 + * Generate default filename 87 + */ 88 + function generateFilename(mimeType, title) { 89 + const ext = getExtensionFromMime(mimeType); 90 + const timestamp = new Date().toISOString().slice(0, 10); 91 + 92 + if (title) { 93 + // Sanitize title for filename 94 + const safeTitle = title.toLowerCase() 95 + .replace(/[^a-z0-9]+/g, '-') 96 + .replace(/^-|-$/g, '') 97 + .substring(0, 30); 98 + return `${safeTitle}-${timestamp}.${ext}`; 99 + } 100 + 101 + return `export-${timestamp}.${ext}`; 102 + } 103 + 104 + // ===== Command definitions ===== 105 + 106 + const commandDefinitions = [ 107 + { 108 + name: 'csv', 109 + description: 'Convert JSON to CSV format', 110 + accepts: ['application/json'], 111 + produces: ['text/csv'], 112 + 113 + execute: async (ctx) => { 114 + console.log('[csv] execute:', ctx); 115 + 116 + // Check if we have input data from chain 117 + if (!ctx.input) { 118 + console.log('[csv] No input data'); 119 + return { 120 + success: false, 121 + error: 'No input data. Use this command in a chain after a command that produces JSON.' 122 + }; 123 + } 124 + 125 + try { 126 + // Parse input if it's a string 127 + let data = ctx.input; 128 + if (typeof data === 'string') { 129 + try { 130 + data = JSON.parse(data); 131 + } catch (e) { 132 + // Not JSON, treat as plain text 133 + return { 134 + success: false, 135 + error: 'Input is not valid JSON' 136 + }; 137 + } 138 + } 139 + 140 + // Ensure we have an array 141 + if (!Array.isArray(data)) { 142 + // If it's an object with an 'items' property, use that 143 + if (data && Array.isArray(data.items)) { 144 + data = data.items; 145 + } else { 146 + // Wrap single object in array 147 + data = [data]; 148 + } 149 + } 150 + 151 + // Convert to CSV 152 + const csvOutput = jsonToCsv(data); 153 + 154 + console.log('[csv] Converted', data.length, 'items to CSV'); 155 + 156 + return { 157 + success: true, 158 + output: { 159 + data: csvOutput, 160 + mimeType: 'text/csv', 161 + title: `CSV (${data.length} rows)` 162 + } 163 + }; 164 + } catch (err) { 165 + console.error('[csv] Error:', err); 166 + return { success: false, error: err.message }; 167 + } 168 + } 169 + }, 170 + { 171 + name: 'save', 172 + description: 'Save data to a file', 173 + accepts: ['*/*'], // Accept any MIME type 174 + produces: [], // End of chain - doesn't produce output 175 + 176 + execute: async (ctx) => { 177 + console.log('[save] execute:', ctx); 178 + 179 + // Check if we have input data from chain 180 + if (!ctx.input) { 181 + console.log('[save] No input data'); 182 + return { 183 + success: false, 184 + error: 'No input data. Use this command in a chain after a command that produces output.' 185 + }; 186 + } 187 + 188 + try { 189 + const data = ctx.input; 190 + const mimeType = ctx.inputMimeType || 'text/plain'; 191 + 192 + // Determine filename - use search arg if provided, otherwise generate 193 + let filename = ctx.search?.trim(); 194 + if (!filename) { 195 + filename = generateFilename(mimeType, ctx.inputTitle); 196 + } 197 + 198 + // Ensure proper extension 199 + if (!filename.includes('.')) { 200 + filename += '.' + getExtensionFromMime(mimeType); 201 + } 202 + 203 + // Stringify data if needed 204 + let content; 205 + if (typeof data === 'string') { 206 + content = data; 207 + } else { 208 + content = JSON.stringify(data, null, 2); 209 + } 210 + 211 + // Send to background script to handle download 212 + // Background persists regardless of panel state 213 + api.publish('cmd:save-file', { 214 + content, 215 + filename, 216 + mimeType 217 + }, api.scopes.GLOBAL); 218 + 219 + console.log('[save] Requested download:', filename); 220 + return { 221 + success: true, 222 + message: `Saving ${filename}...` 223 + }; 224 + } catch (err) { 225 + console.error('[save] Error:', err); 226 + return { success: false, error: err.message }; 227 + } 228 + } 229 + } 230 + ]; 231 + 232 + // ===== Registration ===== 233 + 234 + let registeredCommands = []; 235 + 236 + const initCommands = () => { 237 + commandDefinitions.forEach(cmd => { 238 + api.commands.register(cmd); 239 + registeredCommands.push(cmd.name); 240 + }); 241 + console.log('[ext:files] Registered commands:', registeredCommands); 242 + }; 243 + 244 + const uninitCommands = () => { 245 + registeredCommands.forEach(name => { 246 + api.commands.unregister(name); 247 + }); 248 + registeredCommands = []; 249 + console.log('[ext:files] Unregistered commands'); 250 + }; 251 + 252 + const init = async () => { 253 + console.log('[ext:files] init'); 254 + 255 + // Wait for cmd:ready before registering commands 256 + api.subscribe('cmd:ready', () => { 257 + initCommands(); 258 + }, api.scopes.GLOBAL); 259 + 260 + // Query in case cmd is already ready (it usually is since cmd loads first) 261 + api.publish('cmd:query', {}, api.scopes.GLOBAL); 262 + }; 263 + 264 + const uninit = () => { 265 + console.log('[ext:files] uninit'); 266 + uninitCommands(); 267 + }; 268 + 269 + export default { 270 + id, 271 + init, 272 + uninit, 273 + labels 274 + };
+8
extensions/files/manifest.json
··· 1 + { 2 + "id": "files", 3 + "name": "Files", 4 + "version": "1.0.0", 5 + "description": "File and format commands (CSV, save)", 6 + "background": "background.html", 7 + "builtin": true 8 + }
+50
extensions/page/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Page Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+192
extensions/page/background.js
··· 1 + /** 2 + * Page Extension Background Script 3 + * 4 + * Page and window-related commands (open, modal) 5 + * 6 + * Runs in isolated extension process (peek://ext/page/background.html) 7 + */ 8 + 9 + const api = window.app; 10 + 11 + const id = 'page'; 12 + const labels = { 13 + name: 'Page', 14 + description: 'Page and window commands' 15 + }; 16 + 17 + console.log('[ext:page] background', labels.name); 18 + 19 + // ===== URL Validation ===== 20 + 21 + /** 22 + * Validates and normalizes a URL string 23 + * @param {string} str - The string to check 24 + * @returns {Object} - Object with valid flag and normalized URL 25 + */ 26 + function getValidURL(str) { 27 + // Quick check for empty string 28 + if (!str) return { valid: false }; 29 + 30 + // Check if it starts with a valid protocol 31 + const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str); 32 + 33 + if (!hasValidProtocol) { 34 + // Check if it looks like a domain (e.g., "example.com", "example.com/path", "localhost") 35 + // Pattern: domain.tld or domain.tld/path (with optional port for localhost) 36 + const isDomainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(str); 37 + const isLocalhost = /^localhost(:\d+)?(\/.*)?$/.test(str); 38 + 39 + if (isDomainPattern || isLocalhost) { 40 + // It's a domain without protocol, add https:// 41 + const urlWithProtocol = 'https://' + str; 42 + try { 43 + // Validate the URL with added protocol 44 + new URL(urlWithProtocol); 45 + return { valid: true, url: urlWithProtocol }; 46 + } catch (e) { 47 + return { valid: false }; 48 + } 49 + } 50 + return { valid: false }; 51 + } 52 + 53 + try { 54 + // Already has protocol, just validate 55 + new URL(str); 56 + return { valid: true, url: str }; 57 + } catch (e) { 58 + return { valid: false }; 59 + } 60 + } 61 + 62 + // ===== Command definitions ===== 63 + 64 + const commandDefinitions = [ 65 + { 66 + name: 'open', 67 + description: 'Open a URL in a new window', 68 + execute: async (ctx) => { 69 + console.log('[ext:page] open command', ctx); 70 + 71 + const parts = ctx.typed.split(' '); 72 + parts.shift(); 73 + 74 + const address = parts.shift(); 75 + 76 + if (!address) { 77 + console.log('[ext:page] No address provided'); 78 + return { error: 'No address provided' }; 79 + } 80 + 81 + // Check if the input is a valid URL and get the normalized version 82 + const urlResult = getValidURL(address); 83 + if (!urlResult.valid) { 84 + console.log('[ext:page] Invalid URL:', address); 85 + return { error: 'Invalid URL. Must be a valid URL starting with http://, https://, or other valid protocol.' }; 86 + } 87 + 88 + // Use the normalized URL (with protocol added if needed) 89 + const normalizedAddress = urlResult.url; 90 + console.log('[ext:page] Using normalized URL:', normalizedAddress); 91 + 92 + try { 93 + const result = await api.window.open(normalizedAddress, { 94 + width: 800, 95 + height: 600, 96 + trackingSource: 'cmd', 97 + trackingSourceId: 'open' 98 + }); 99 + console.log('[ext:page] Window opened:', result); 100 + 101 + return { 102 + command: 'open', 103 + address: normalizedAddress, 104 + success: true 105 + }; 106 + } catch (error) { 107 + console.error('[ext:page] Failed to open window:', error); 108 + return { 109 + error: 'Failed to open window: ' + error.message, 110 + address: normalizedAddress 111 + }; 112 + } 113 + } 114 + }, 115 + { 116 + name: 'modal', 117 + description: 'Open a URL in a modal window that hides on blur or escape', 118 + execute: async (ctx) => { 119 + console.log('[ext:page] modal command', ctx); 120 + 121 + const parts = ctx.typed.split(' '); 122 + parts.shift(); 123 + 124 + const address = parts.shift(); 125 + 126 + if (!address) { 127 + console.log('[ext:page] No address provided'); 128 + return { error: 'No address provided' }; 129 + } 130 + 131 + try { 132 + const result = await api.window.openModal(address, { 133 + width: 700, 134 + height: 500 135 + }); 136 + console.log('[ext:page] Modal window opened:', result); 137 + } catch (error) { 138 + console.error('[ext:page] Failed to open modal window:', error); 139 + return { error: 'Failed to open modal window: ' + error.message }; 140 + } 141 + 142 + return { 143 + command: 'modal', 144 + address 145 + }; 146 + } 147 + } 148 + ]; 149 + 150 + // ===== Registration ===== 151 + 152 + let registeredCommands = []; 153 + 154 + const initCommands = () => { 155 + commandDefinitions.forEach(cmd => { 156 + api.commands.register(cmd); 157 + registeredCommands.push(cmd.name); 158 + }); 159 + console.log('[ext:page] Registered commands:', registeredCommands); 160 + }; 161 + 162 + const uninitCommands = () => { 163 + registeredCommands.forEach(name => { 164 + api.commands.unregister(name); 165 + }); 166 + registeredCommands = []; 167 + console.log('[ext:page] Unregistered commands'); 168 + }; 169 + 170 + const init = async () => { 171 + console.log('[ext:page] init'); 172 + 173 + // Wait for cmd:ready before registering commands 174 + api.subscribe('cmd:ready', () => { 175 + initCommands(); 176 + }, api.scopes.GLOBAL); 177 + 178 + // Query in case cmd is already ready (it usually is since cmd loads first) 179 + api.publish('cmd:query', {}, api.scopes.GLOBAL); 180 + }; 181 + 182 + const uninit = () => { 183 + console.log('[ext:page] uninit'); 184 + uninitCommands(); 185 + }; 186 + 187 + export default { 188 + id, 189 + init, 190 + uninit, 191 + labels 192 + };
+8
extensions/page/manifest.json
··· 1 + { 2 + "id": "page", 3 + "name": "Page", 4 + "version": "1.0.0", 5 + "description": "Page and window commands", 6 + "background": "background.html", 7 + "builtin": true 8 + }
+380
extensions/tags/background.js
··· 6 6 * - Tag-based filtering via clickable tag buttons 7 7 * - Tag editing on items 8 8 * - Search across items and tags 9 + * - Commands: tag, tags, untag, tagset 9 10 */ 10 11 11 12 // Feature detection - check if Peek API is available ··· 54 55 return { success: true, tags: result.data }; 55 56 } 56 57 58 + // ============================================================================ 59 + // Tag command helpers 60 + // ============================================================================ 61 + 62 + /** 63 + * Get the most recently focused non-internal window 64 + */ 65 + const getActiveWindow = async () => { 66 + const result = await api.window.list({ includeInternal: false }); 67 + if (!result.success || !result.windows.length) { 68 + return null; 69 + } 70 + // Return the first non-internal window 71 + return result.windows[0]; 72 + }; 73 + 74 + /** 75 + * Find item record by URL content 76 + */ 77 + const findItemByUrl = async (url) => { 78 + const result = await api.datastore.queryItems({ type: 'url' }); 79 + if (!result.success) return null; 80 + 81 + return result.data.find(item => item.content === url) || null; 82 + }; 83 + 84 + /** 85 + * Add tags to an item using the join table 86 + */ 87 + const addTagsToItem = async (itemId, tagNames) => { 88 + const results = []; 89 + 90 + for (const tagName of tagNames) { 91 + // Get or create the tag 92 + api.log('[tag] Getting/creating tag:', tagName); 93 + const tagResult = await api.datastore.getOrCreateTag(tagName); 94 + api.log('[tag] getOrCreateTag result:', JSON.stringify(tagResult)); 95 + if (!tagResult.success) { 96 + console.error('Failed to get/create tag:', tagName, tagResult.error); 97 + continue; 98 + } 99 + 100 + const tag = tagResult.data.tag; 101 + api.log('[tag] Tag id:', tag.id, 'name:', tag.name); 102 + 103 + // Link tag to item 104 + api.log('[tag] Linking tag', tag.id, 'to item', itemId); 105 + const linkResult = await api.datastore.tagItem(itemId, tag.id); 106 + api.log('[tag] tagItem result:', JSON.stringify(linkResult)); 107 + if (!linkResult.success) { 108 + console.error('Failed to link tag:', tagName, linkResult.error); 109 + continue; 110 + } 111 + 112 + results.push({ 113 + tag, 114 + alreadyExists: linkResult.alreadyExists 115 + }); 116 + } 117 + 118 + return results; 119 + }; 120 + 121 + /** 122 + * Remove tags from an item 123 + */ 124 + const removeTagsFromItem = async (itemId, tagNames) => { 125 + const results = []; 126 + 127 + // Get current tags for item 128 + const tagsResult = await api.datastore.getItemTags(itemId); 129 + if (!tagsResult.success) { 130 + return results; 131 + } 132 + 133 + for (const tagName of tagNames) { 134 + // Find the tag by name 135 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === tagName.toLowerCase()); 136 + if (!tag) { 137 + console.log('Tag not found on item:', tagName); 138 + continue; 139 + } 140 + 141 + // Unlink tag from item 142 + const unlinkResult = await api.datastore.untagItem(itemId, tag.id); 143 + results.push({ 144 + tag, 145 + removed: unlinkResult.success 146 + }); 147 + } 148 + 149 + return results; 150 + }; 151 + 152 + /** 153 + * Get tags for an item 154 + */ 155 + const getTagsForItem = async (itemId) => { 156 + const result = await api.datastore.getItemTags(itemId); 157 + if (!result.success) return []; 158 + return result.data; 159 + }; 160 + 161 + // ============================================================================ 162 + // Tag command execute functions 163 + // ============================================================================ 164 + 165 + /** 166 + * Tag command - add tags to the URL of the active window 167 + * Usage: 168 + * tag foo - add tag "foo" to active window's URL 169 + * tag foo bar - add multiple tags 170 + * tag -r foo - remove tag "foo" from active window 171 + * tag - show tags for active window 172 + */ 173 + async function executeTag(ctx) { 174 + // Get active window 175 + api.log('tag command execute, ctx:', ctx); 176 + const activeWindow = await getActiveWindow(); 177 + api.log('tag command: activeWindow =', activeWindow); 178 + if (!activeWindow) { 179 + api.log('No active window found'); 180 + return { success: false, error: 'No active window' }; 181 + } 182 + 183 + const url = activeWindow.url; 184 + api.log('Tagging URL:', url); 185 + 186 + // Find item in datastore 187 + let item = await findItemByUrl(url); 188 + 189 + // If no item exists, create one 190 + if (!item) { 191 + api.log('[tag] Creating new item for URL:', url); 192 + const addResult = await api.datastore.addItem('url', { 193 + content: url, 194 + metadata: JSON.stringify({ title: activeWindow.title || '' }) 195 + }); 196 + api.log('[tag] addItem result:', JSON.stringify(addResult)); 197 + if (!addResult.success) { 198 + console.error('Failed to create item:', addResult.error); 199 + return { success: false, error: 'Failed to create item' }; 200 + } 201 + item = { id: addResult.data.id }; 202 + api.log('[tag] Created item with id:', item.id); 203 + } else { 204 + api.log('[tag] Found existing item:', item.id, 'for URL:', url); 205 + } 206 + 207 + // No args - show current tags 208 + if (!ctx.search) { 209 + const tags = await getTagsForItem(item.id); 210 + if (tags.length === 0) { 211 + console.log('No tags for:', url); 212 + } else { 213 + console.log('Tags for', url + ':'); 214 + tags.forEach(t => console.log(' -', t.name, `(frecency: ${t.frecencyScore?.toFixed(1) || 0})`)); 215 + } 216 + return { success: true, tags }; 217 + } 218 + 219 + // Parse args 220 + // If comma present, split on comma; otherwise split on spaces 221 + const input = ctx.search.trim(); 222 + const hasComma = input.includes(','); 223 + let args; 224 + if (hasComma) { 225 + args = input.split(',').map(s => s.trim()).filter(s => s.length > 0); 226 + } else { 227 + args = input.split(/\s+/); 228 + } 229 + const removeMode = args[0] === '-r'; 230 + const tagsToProcess = removeMode ? args.slice(1) : args; 231 + 232 + if (tagsToProcess.length === 0) { 233 + console.log('No tags specified'); 234 + return { success: false, error: 'No tags specified' }; 235 + } 236 + 237 + // Add or remove tags 238 + if (removeMode) { 239 + const results = await removeTagsFromItem(item.id, tagsToProcess); 240 + const removed = results.filter(r => r.removed).map(r => r.tag.name); 241 + if (removed.length > 0) { 242 + console.log('Removed tags:', removed.join(', '), 'from', url); 243 + } 244 + return { success: true, removed }; 245 + } else { 246 + api.log('Adding tags to item:', item.id, 'tags:', tagsToProcess); 247 + const results = await addTagsToItem(item.id, tagsToProcess); 248 + api.log('addTagsToItem results:', results); 249 + const added = results.filter(r => !r.alreadyExists).map(r => r.tag.name); 250 + const existing = results.filter(r => r.alreadyExists).map(r => r.tag.name); 251 + if (added.length > 0) { 252 + console.log('Added tags:', added.join(', '), 'to', url); 253 + } 254 + if (existing.length > 0) { 255 + console.log('Already tagged:', existing.join(', ')); 256 + } 257 + return { success: true, added, existing }; 258 + } 259 + } 260 + 261 + /** 262 + * Tags command - show tags for the active window URL (or all tags by frecency) 263 + */ 264 + async function executeTags(ctx) { 265 + // If search term provided, show all tags matching 266 + if (ctx.search) { 267 + const result = await api.datastore.getTagsByFrecency(); 268 + if (!result.success) { 269 + console.log('Failed to get tags'); 270 + return { success: false }; 271 + } 272 + 273 + const filter = ctx.search.toLowerCase(); 274 + const filtered = result.data.filter(t => t.name.toLowerCase().includes(filter)); 275 + 276 + if (filtered.length === 0) { 277 + console.log('No tags matching:', ctx.search); 278 + } else { 279 + console.log('Tags matching "' + ctx.search + '":'); 280 + filtered.forEach(t => { 281 + console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`); 282 + }); 283 + } 284 + return { success: true, tags: filtered }; 285 + } 286 + 287 + // No args - show tags for active window 288 + const activeWindow = await getActiveWindow(); 289 + if (!activeWindow) { 290 + // No active window - show all tags by frecency 291 + const result = await api.datastore.getTagsByFrecency(); 292 + if (!result.success) { 293 + console.log('Failed to get tags'); 294 + return { success: false }; 295 + } 296 + 297 + if (result.data.length === 0) { 298 + console.log('No tags yet'); 299 + } else { 300 + console.log('All tags (by frecency):'); 301 + result.data.slice(0, 20).forEach(t => { 302 + console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`); 303 + }); 304 + } 305 + return { success: true, tags: result.data }; 306 + } 307 + 308 + const item = await findItemByUrl(activeWindow.url); 309 + if (!item) { 310 + console.log('No tags for:', activeWindow.url); 311 + return { success: true, tags: [] }; 312 + } 313 + 314 + const tags = await getTagsForItem(item.id); 315 + 316 + if (tags.length === 0) { 317 + console.log('No tags for:', activeWindow.url); 318 + } else { 319 + console.log('Tags for', activeWindow.url + ':'); 320 + tags.forEach(t => console.log(' -', t.name)); 321 + } 322 + 323 + return { success: true, tags }; 324 + } 325 + 326 + /** 327 + * Untag command - remove tags from the active window URL 328 + */ 329 + async function executeUntag(ctx) { 330 + if (!ctx.search) { 331 + console.log('Usage: untag <tag1> [tag2] ...'); 332 + return { success: false, error: 'No tags specified' }; 333 + } 334 + 335 + // Delegate to tag -r 336 + return executeTag({ search: '-r ' + ctx.search }); 337 + } 338 + 339 + // ============================================================================ 340 + // Tagset command 341 + // ============================================================================ 342 + 343 + /** 344 + * Create a new tagset with the specified tags 345 + * @param {string} tagsString - Comma-separated list of tag names 346 + * @returns {Promise<Object>} The ID and tags of the created tagset 347 + */ 348 + const createTagset = async (tagsString) => { 349 + // Parse tags from comma-separated string 350 + const tagNames = tagsString 351 + .split(',') 352 + .map(t => t.trim()) 353 + .filter(t => t.length > 0); 354 + 355 + if (tagNames.length === 0) { 356 + throw new Error('No valid tags provided'); 357 + } 358 + 359 + // Create the tagset item 360 + const result = await api.datastore.addItem('tagset', { 361 + content: tagNames.join(', ') 362 + }); 363 + 364 + if (!result.success) { 365 + throw new Error(result.error || 'Failed to create tagset'); 366 + } 367 + 368 + const itemId = result.data.id; 369 + 370 + // Add each tag to the tagset 371 + for (const tagName of tagNames) { 372 + const tagResult = await api.datastore.getOrCreateTag(tagName); 373 + if (tagResult.success) { 374 + await api.datastore.tagItem(itemId, tagResult.data.tag.id); 375 + } 376 + } 377 + 378 + // Also add the 'from:cmd' tag to track origin 379 + const fromCmdResult = await api.datastore.getOrCreateTag('from:cmd'); 380 + if (fromCmdResult.success) { 381 + await api.datastore.tagItem(itemId, fromCmdResult.data.tag.id); 382 + } 383 + 384 + return { id: itemId, tags: tagNames }; 385 + }; 386 + 387 + /** 388 + * Tagset command - creates tagset items in the datastore 389 + * Tagsets are items of type='tagset' that exist solely to hold a combination of tags 390 + */ 391 + async function executeTagset(ctx) { 392 + if (ctx.search) { 393 + try { 394 + const { id, tags } = await createTagset(ctx.search); 395 + console.log(`Tagset created with ID: ${id}`); 396 + console.log(`Tags: ${tags.join(', ')}`); 397 + api.publish('editor:changed', { action: 'add', itemId: id }, api.scopes.GLOBAL); 398 + return { success: true, message: `Tagset created with tags: ${tags.join(', ')}` }; 399 + } catch (error) { 400 + console.error('Failed to create tagset:', error); 401 + return { success: false, message: error.message }; 402 + } 403 + } else { 404 + api.publish('editor:add', { type: 'tagset' }, api.scopes.GLOBAL); 405 + return { success: true, message: 'Opening editor' }; 406 + } 407 + } 408 + 57 409 const extension = { 58 410 id: 'tags', 59 411 labels: { ··· 76 428 name: 'list tags', 77 429 description: 'List all tags by frecency', 78 430 execute: listTags 431 + }); 432 + 433 + // Tag command - add tags to the active window URL 434 + api.commands.register({ 435 + name: 'tag', 436 + description: 'Add tags to the active window URL', 437 + execute: executeTag 438 + }); 439 + 440 + // Tags command - show tags for active window or all tags 441 + api.commands.register({ 442 + name: 'tags', 443 + description: 'Show tags for the active window URL (or all tags by frecency)', 444 + execute: executeTags 445 + }); 446 + 447 + // Untag command - remove tags from active window 448 + api.commands.register({ 449 + name: 'untag', 450 + description: 'Remove tags from the active window URL', 451 + execute: executeUntag 452 + }); 453 + 454 + // Tagset command - create tagset items 455 + api.commands.register({ 456 + name: 'tagset', 457 + description: 'Create a tagset with specified tags', 458 + execute: executeTagset 79 459 }); 80 460 81 461 console.log('[tags] Commands registered');