···11-/**
22- * CSV command - convert JSON data to CSV format
33- *
44- * This is a chaining-enabled command that accepts JSON input
55- * and produces CSV output.
66- *
77- * Usage in chain:
88- * lists → csv → save
99- */
1010-1111-/**
1212- * Convert JSON array to CSV string
1313- */
1414-function jsonToCsv(data) {
1515- if (!Array.isArray(data) || data.length === 0) {
1616- return '';
1717- }
1818-1919- // Get all unique keys from all objects
2020- const keys = new Set();
2121- data.forEach(item => {
2222- if (typeof item === 'object' && item !== null) {
2323- Object.keys(item).forEach(key => keys.add(key));
2424- }
2525- });
2626-2727- const headers = Array.from(keys);
2828-2929- // Escape CSV value
3030- const escapeValue = (val) => {
3131- if (val === null || val === undefined) return '';
3232- const str = String(val);
3333- // If contains comma, newline, or quote, wrap in quotes and escape existing quotes
3434- if (str.includes(',') || str.includes('\n') || str.includes('"')) {
3535- return '"' + str.replace(/"/g, '""') + '"';
3636- }
3737- return str;
3838- };
3939-4040- // Build CSV
4141- const lines = [];
4242-4343- // Header row
4444- lines.push(headers.map(escapeValue).join(','));
4545-4646- // Data rows
4747- data.forEach(item => {
4848- if (typeof item === 'object' && item !== null) {
4949- const row = headers.map(header => escapeValue(item[header]));
5050- lines.push(row.join(','));
5151- } else {
5252- // Simple value, put in first column
5353- lines.push(escapeValue(item));
5454- }
5555- });
5656-5757- return lines.join('\n');
5858-}
5959-6060-export default {
6161- name: 'csv',
6262- description: 'Convert JSON to CSV format',
6363- accepts: ['application/json'],
6464- produces: ['text/csv'],
6565-6666- execute: async (ctx) => {
6767- console.log('[csv] execute:', ctx);
6868-6969- // Check if we have input data from chain
7070- if (!ctx.input) {
7171- console.log('[csv] No input data');
7272- return {
7373- success: false,
7474- error: 'No input data. Use this command in a chain after a command that produces JSON.'
7575- };
7676- }
7777-7878- try {
7979- // Parse input if it's a string
8080- let data = ctx.input;
8181- if (typeof data === 'string') {
8282- try {
8383- data = JSON.parse(data);
8484- } catch (e) {
8585- // Not JSON, treat as plain text
8686- return {
8787- success: false,
8888- error: 'Input is not valid JSON'
8989- };
9090- }
9191- }
9292-9393- // Ensure we have an array
9494- if (!Array.isArray(data)) {
9595- // If it's an object with an 'items' property, use that
9696- if (data && Array.isArray(data.items)) {
9797- data = data.items;
9898- } else {
9999- // Wrap single object in array
100100- data = [data];
101101- }
102102- }
103103-104104- // Convert to CSV
105105- const csvOutput = jsonToCsv(data);
106106-107107- console.log('[csv] Converted', data.length, 'items to CSV');
108108-109109- return {
110110- success: true,
111111- output: {
112112- data: csvOutput,
113113- mimeType: 'text/csv',
114114- title: `CSV (${data.length} rows)`
115115- }
116116- };
117117- } catch (err) {
118118- console.error('[csv] Error:', err);
119119- return { success: false, error: err.message };
120120- }
121121- }
122122-};
+2-4
extensions/cmd/commands/index.js
···22 * Commands module - exports all available commands
33 * Note: groups commands are now provided by the groups extension
44 * Note: sync command is now provided by the sync extension
55+ * Note: open/modal commands are now provided by the page extension
56 */
66-import openCommand from './open.js';
77import debugCommand from './debug.js';
88-import modalCommand from './modal.js';
98import noteModule from './note.js';
109import tagsetModule from './tagset.js';
1110import urlModule from './url.js';
···3332// Active commands - only these will be loaded
3433// Note: groups commands are dynamically registered by the groups extension
3534// Note: sync command is dynamically registered by the sync extension
3535+// Note: open/modal commands are dynamically registered by the page extension
3636const activeCommands = [
3737- openCommand,
3837 debugCommand,
3939- modalCommand,
4038 ...noteModule.commands,
4139 ...tagsetModule.commands,
4240 ...urlModule.commands,
-36
extensions/cmd/commands/modal.js
···11-/**
22- * Modal command - opens a URL in a modal window that hides on blur or escape
33- */
44-import windows from 'peek://app/windows.js';
55-66-export default {
77- name: 'modal',
88- execute: async (msg) => {
99- console.log('modal command', msg);
1010-1111- const parts = msg.typed.split(' ');
1212- parts.shift();
1313-1414- const address = parts.shift();
1515-1616- if (!address) {
1717- return;
1818- }
1919-2020- // Use the modal window API
2121- try {
2222- const result = await windows.openModalWindow(address, {
2323- width: 700,
2424- height: 500
2525- });
2626- console.log('Modal window opened:', result);
2727- } catch (error) {
2828- console.error('Failed to open modal window:', error);
2929- }
3030-3131- return {
3232- command: 'modal',
3333- address
3434- };
3535- }
3636-};
-98
extensions/cmd/commands/open.js
···11-/**
22- * Open command - opens a URL in a new window
33- * Only opens window if input is a valid URL
44- */
55-import windows from 'peek://app/windows.js';
66-77-export default {
88- name: 'open',
99- execute: async (msg) => {
1010- console.log('open command', msg);
1111-1212- const parts = msg.typed.split(' ');
1313- parts.shift();
1414-1515- const address = parts.shift();
1616-1717- if (!address) {
1818- console.log('No address provided');
1919- return { error: 'No address provided' };
2020- }
2121-2222- // Check if the input is a valid URL and get the normalized version
2323- const urlResult = getValidURL(address);
2424- if (!urlResult.valid) {
2525- console.log('Invalid URL:', address);
2626- return { error: 'Invalid URL. Must be a valid URL starting with http://, https://, or other valid protocol.' };
2727- }
2828-2929- // Use the normalized URL (with protocol added if needed)
3030- const normalizedAddress = urlResult.url;
3131- console.log('Using normalized URL:', normalizedAddress);
3232-3333- // Use the new windows API (tracking handled automatically)
3434- try {
3535- const windowController = await windows.createWindow(normalizedAddress, {
3636- width: 800,
3737- height: 600,
3838- openDevTools: window.app.debug,
3939- trackingSource: 'cmd',
4040- trackingSourceId: 'open'
4141- });
4242- console.log('Window opened with ID:', windowController.id);
4343-4444- return {
4545- command: 'open',
4646- address: normalizedAddress,
4747- success: true
4848- };
4949- } catch (error) {
5050- console.error('Failed to open window:', error);
5151- return {
5252- error: 'Failed to open window: ' + error.message,
5353- address: normalizedAddress
5454- };
5555- }
5656- }
5757-};
5858-5959-/**
6060- * Validates and normalizes a URL string
6161- * @param {string} str - The string to check
6262- * @returns {Object} - Object with valid flag and normalized URL
6363- */
6464-function getValidURL(str) {
6565- // Quick check for empty string
6666- if (!str) return { valid: false };
6767-6868- // Check if it starts with a valid protocol
6969- const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str);
7070-7171- if (!hasValidProtocol) {
7272- // Check if it looks like a domain (e.g., "example.com", "example.com/path", "localhost")
7373- // Pattern: domain.tld or domain.tld/path (with optional port for localhost)
7474- const isDomainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(str);
7575- const isLocalhost = /^localhost(:\d+)?(\/.*)?$/.test(str);
7676-7777- if (isDomainPattern || isLocalhost) {
7878- // It's a domain without protocol, add https://
7979- const urlWithProtocol = 'https://' + str;
8080- try {
8181- // Validate the URL with added protocol
8282- new URL(urlWithProtocol);
8383- return { valid: true, url: urlWithProtocol };
8484- } catch (e) {
8585- return { valid: false };
8686- }
8787- }
8888- return { valid: false };
8989- }
9090-9191- try {
9292- // Already has protocol, just validate
9393- new URL(str);
9494- return { valid: true, url: str };
9595- } catch (e) {
9696- return { valid: false };
9797- }
9898-}
-107
extensions/cmd/commands/save.js
···11-/**
22- * Save command - save data to a file
33- *
44- * This is a chaining-enabled command that accepts any input
55- * and saves it as a file download.
66- *
77- * Usage in chain:
88- * lists → csv → save myfile.csv
99- * lists → save data.json
1010- */
1111-1212-const api = window.app;
1313-1414-/**
1515- * Get file extension from MIME type
1616- */
1717-function getExtensionFromMime(mimeType) {
1818- const mimeToExt = {
1919- 'application/json': 'json',
2020- 'text/csv': 'csv',
2121- 'text/plain': 'txt',
2222- 'text/html': 'html',
2323- 'application/xml': 'xml',
2424- 'text/xml': 'xml'
2525- };
2626- return mimeToExt[mimeType] || 'txt';
2727-}
2828-2929-/**
3030- * Generate default filename
3131- */
3232-function generateFilename(mimeType, title) {
3333- const ext = getExtensionFromMime(mimeType);
3434- const timestamp = new Date().toISOString().slice(0, 10);
3535-3636- if (title) {
3737- // Sanitize title for filename
3838- const safeTitle = title.toLowerCase()
3939- .replace(/[^a-z0-9]+/g, '-')
4040- .replace(/^-|-$/g, '')
4141- .substring(0, 30);
4242- return `${safeTitle}-${timestamp}.${ext}`;
4343- }
4444-4545- return `export-${timestamp}.${ext}`;
4646-}
4747-4848-export default {
4949- name: 'save',
5050- description: 'Save data to a file',
5151- accepts: ['*/*'], // Accept any MIME type
5252- produces: [], // End of chain - doesn't produce output
5353-5454- execute: async (ctx) => {
5555- console.log('[save] execute:', ctx);
5656-5757- // Check if we have input data from chain
5858- if (!ctx.input) {
5959- console.log('[save] No input data');
6060- return {
6161- success: false,
6262- error: 'No input data. Use this command in a chain after a command that produces output.'
6363- };
6464- }
6565-6666- try {
6767- const data = ctx.input;
6868- const mimeType = ctx.inputMimeType || 'text/plain';
6969-7070- // Determine filename - use search arg if provided, otherwise generate
7171- let filename = ctx.search?.trim();
7272- if (!filename) {
7373- filename = generateFilename(mimeType, ctx.inputTitle);
7474- }
7575-7676- // Ensure proper extension
7777- if (!filename.includes('.')) {
7878- filename += '.' + getExtensionFromMime(mimeType);
7979- }
8080-8181- // Stringify data if needed
8282- let content;
8383- if (typeof data === 'string') {
8484- content = data;
8585- } else {
8686- content = JSON.stringify(data, null, 2);
8787- }
8888-8989- // Send to background script to handle download
9090- // Background persists regardless of panel state
9191- api.publish('cmd:save-file', {
9292- content,
9393- filename,
9494- mimeType
9595- }, api.scopes.GLOBAL);
9696-9797- console.log('[save] Requested download:', filename);
9898- return {
9999- success: true,
100100- message: `Saving ${filename}...`
101101- };
102102- } catch (err) {
103103- console.error('[save] Error:', err);
104104- return { success: false, error: err.message };
105105- }
106106- }
107107-};