···11/**
22- * Groups command - save and open window groups using machine tags
33- * Groups are stored as machine tags on addresses: "group:groupname"
22+ * Groups command - manage groups (tags) and their addresses
33+ * Groups are implemented as tags in the datastore
44 */
55import windows from '../../windows.js';
66import api from '../../api.js';
7788-const GROUP_TAG_PREFIX = 'group:';
99-1010-/**
1111- * Helper to add a tag to an address's tags string
1212- */
1313-const addTagToAddress = async (addressId, tag, currentTags) => {
1414- const tagsArray = currentTags ? currentTags.split(',').map(t => t.trim()).filter(t => t) : [];
1515- if (!tagsArray.includes(tag)) {
1616- tagsArray.push(tag);
1717- }
1818- const newTags = tagsArray.join(',');
1919- return api.datastore.updateAddress(addressId, { tags: newTags });
2020-};
88+const GROUPS_ADDRESS = 'peek://app/groups/home.html';
2192210/**
2311 * Helper to get or create an address for a URI
···3725};
38263927/**
4040- * Get all group names from existing tags
4141- */
4242-const getGroupNames = async () => {
4343- const result = await api.datastore.queryAddresses({});
4444- if (!result.success) return [];
4545-4646- const groupNames = new Set();
4747- result.data.forEach(addr => {
4848- if (addr.tags) {
4949- addr.tags.split(',').forEach(tag => {
5050- const trimmed = tag.trim();
5151- if (trimmed.startsWith(GROUP_TAG_PREFIX)) {
5252- groupNames.add(trimmed.substring(GROUP_TAG_PREFIX.length));
5353- }
5454- });
5555- }
5656- });
5757-5858- return Array.from(groupNames);
5959-};
6060-6161-/**
6262- * Get addresses in a group
2828+ * Get all tags (groups) sorted by frecency
6329 */
6464-const getGroupAddresses = async (groupName) => {
6565- const tag = GROUP_TAG_PREFIX + groupName;
6666- const result = await api.datastore.queryAddresses({ tag });
3030+const getAllGroups = async () => {
3131+ const result = await api.datastore.getTagsByFrecency();
6732 if (!result.success) return [];
6833 return result.data;
6934};
70357136/**
7272- * Save current windows as a group
3737+ * Save current windows to a group (tag)
7338 */
7474-const saveGroup = async (groupName) => {
7575- console.log('Saving group:', groupName);
3939+const saveToGroup = async (groupName) => {
4040+ console.log('Saving to group:', groupName);
4141+4242+ // Get or create the tag
4343+ const tagResult = await api.datastore.getOrCreateTag(groupName);
4444+ if (!tagResult.success) {
4545+ console.error('Failed to get/create tag:', tagResult.error);
4646+ return { success: false, error: tagResult.error };
4747+ }
4848+4949+ const tagId = tagResult.data.id;
76507751 // Get all open windows (excluding internal peek:// URLs)
7878- const listResult = await api.window.list();
5252+ const listResult = await api.window.list({ includeInternal: false });
7953 if (!listResult.success || listResult.windows.length === 0) {
8054 console.log('No windows to save');
8155 return { success: false, error: 'No windows to save' };
8256 }
83578484- const tag = GROUP_TAG_PREFIX + groupName;
8558 let savedCount = 0;
86598760 for (const win of listResult.windows) {
8861 const addr = await getOrCreateAddress(win.url);
8962 if (addr) {
9090- await addTagToAddress(addr.id, tag, addr.tags || '');
9191- savedCount++;
6363+ const linkResult = await api.datastore.tagAddress(addr.id, tagId);
6464+ if (linkResult.success && !linkResult.alreadyExists) {
6565+ savedCount++;
6666+ }
9267 }
9368 }
94699570 console.log(`Saved ${savedCount} addresses to group "${groupName}"`);
9696- return { success: true, count: savedCount };
7171+ return { success: true, count: savedCount, total: listResult.windows.length };
9772};
98739974/**
100100- * Open all addresses in a group
7575+ * Open all addresses in a group (tag)
10176 */
10277const openGroup = async (groupName) => {
10378 console.log('Opening group:', groupName);
10479105105- const addresses = await getGroupAddresses(groupName);
106106- if (addresses.length === 0) {
8080+ // Find the tag by name
8181+ const tagsResult = await api.datastore.getTagsByFrecency();
8282+ if (!tagsResult.success) {
8383+ return { success: false, error: 'Failed to get tags' };
8484+ }
8585+8686+ const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase());
8787+ if (!tag) {
8888+ console.log('Group not found:', groupName);
8989+ return { success: false, error: 'Group not found' };
9090+ }
9191+9292+ // Get addresses with this tag
9393+ const addressesResult = await api.datastore.getAddressesByTag(tag.id);
9494+ if (!addressesResult.success || addressesResult.data.length === 0) {
10795 console.log('No addresses in group:', groupName);
108108- return { success: false, error: 'Group is empty or not found' };
9696+ return { success: false, error: 'Group is empty' };
10997 }
11098111111- for (const addr of addresses) {
9999+ for (const addr of addressesResult.data) {
112100 await windows.createWindow(addr.uri, {
113101 trackingSource: 'cmd',
114102 trackingSourceId: `group:${groupName}`
115103 });
116104 }
117105118118- console.log(`Opened ${addresses.length} windows from group "${groupName}"`);
119119- return { success: true, count: addresses.length };
106106+ console.log(`Opened ${addressesResult.data.length} windows from group "${groupName}"`);
107107+ return { success: true, count: addressesResult.data.length };
120108};
121109122122-// Base commands
110110+// Commands
123111const commands = [
124112 {
113113+ name: 'groups',
114114+ description: 'Open the groups manager',
115115+ async execute(ctx) {
116116+ console.log('Opening groups manager');
117117+ await windows.createWindow(GROUPS_ADDRESS, {
118118+ width: 800,
119119+ height: 600,
120120+ trackingSource: 'cmd',
121121+ trackingSourceId: 'groups'
122122+ });
123123+ }
124124+ },
125125+ {
125126 name: 'save group',
127127+ description: 'Save open windows to a group',
126128 async execute(ctx) {
127129 if (ctx.search) {
128128- const groupName = ctx.search.trim().replace(/\s+/g, '-').toLowerCase();
129129- await saveGroup(groupName);
130130+ const groupName = ctx.search.trim();
131131+ const result = await saveToGroup(groupName);
132132+ if (result.success) {
133133+ console.log(`Saved ${result.count} of ${result.total} windows to "${groupName}"`);
134134+ }
130135 } else {
131136 console.log('Usage: save group <name>');
132137 }
133138 }
134134- }
135135-];
136136-137137-/**
138138- * Initialize dynamic group commands
139139- * Adds "open group <name>" commands for each saved group
140140- */
141141-export const initializeSources = async (addCommand) => {
142142- const groupNames = await getGroupNames();
143143- console.log('Found groups:', groupNames);
144144-145145- groupNames.forEach(groupName => {
146146- addCommand({
147147- name: `open group ${groupName}`,
148148- async execute(ctx) {
139139+ },
140140+ {
141141+ name: 'open group',
142142+ description: 'Open all addresses in a group',
143143+ async execute(ctx) {
144144+ if (ctx.search) {
145145+ const groupName = ctx.search.trim();
149146 await openGroup(groupName);
147147+ } else {
148148+ // Show available groups
149149+ const groups = await getAllGroups();
150150+ if (groups.length === 0) {
151151+ console.log('No groups saved yet. Use "save group <name>" to create one.');
152152+ } else {
153153+ console.log('Available groups:');
154154+ groups.forEach(g => console.log(' -', g.name));
155155+ }
150156 }
151151- });
152152- });
153153-};
157157+ }
158158+ }
159159+];
154160155161export default {
156156- commands,
157157- initializeSources
162162+ commands
158163};
+3-2
app/cmd/commands/index.js
···77import groupsModule from './groups.js';
88import noteModule from './note.js';
99import historyModule from './history.js';
1010+import tagModule from './tag.js';
10111112// Source commands (commented out as they need browser extension APIs)
1213// These modules contain command sources that dynamically generate commands
···2728 modalCommand,
2829 ...groupsModule.commands,
2930 ...noteModule.commands,
3030- ...historyModule.commands
3131+ ...historyModule.commands,
3232+ ...tagModule.commands
3133];
32343335// Inactive commands - these require browser extension APIs and are not loaded
···49515052// Source commands - these are modules that generate multiple commands dynamically
5153const sources = [
5252- groupsModule,
5354 historyModule
5455];
5556
+262
app/cmd/commands/tag.js
···11+/**
22+ * Tag command - add tags to the URL of the active window
33+ * Tags are saved using the proper join table (address_tags) with frecency tracking
44+ *
55+ * Usage:
66+ * tag foo - add tag "foo" to active window's URL
77+ * tag foo bar - add multiple tags
88+ * tag -r foo - remove tag "foo" from active window
99+ * tag - show tags for active window
1010+ */
1111+import api from '../../api.js';
1212+1313+/**
1414+ * Get the most recently focused non-internal window
1515+ */
1616+const getActiveWindow = async () => {
1717+ const result = await api.window.list({ includeInternal: false });
1818+ if (!result.success || !result.windows.length) {
1919+ return null;
2020+ }
2121+ // Return the first non-internal window
2222+ return result.windows[0];
2323+};
2424+2525+/**
2626+ * Find address record by URI
2727+ */
2828+const findAddressByUri = async (uri) => {
2929+ const result = await api.datastore.queryAddresses({});
3030+ if (!result.success) return null;
3131+3232+ return result.data.find(addr => addr.uri === uri) || null;
3333+};
3434+3535+/**
3636+ * Add tags to an address using the join table
3737+ */
3838+const addTagsToAddress = async (addressId, tagNames) => {
3939+ const results = [];
4040+4141+ for (const tagName of tagNames) {
4242+ // Get or create the tag
4343+ const tagResult = await api.datastore.getOrCreateTag(tagName);
4444+ if (!tagResult.success) {
4545+ console.error('Failed to get/create tag:', tagName, tagResult.error);
4646+ continue;
4747+ }
4848+4949+ // Link tag to address
5050+ const linkResult = await api.datastore.tagAddress(addressId, tagResult.data.id);
5151+ if (!linkResult.success) {
5252+ console.error('Failed to link tag:', tagName, linkResult.error);
5353+ continue;
5454+ }
5555+5656+ results.push({
5757+ tag: tagResult.data,
5858+ alreadyExists: linkResult.alreadyExists
5959+ });
6060+ }
6161+6262+ return results;
6363+};
6464+6565+/**
6666+ * Remove tags from an address
6767+ */
6868+const removeTagsFromAddress = async (addressId, tagNames) => {
6969+ const results = [];
7070+7171+ // Get current tags for address
7272+ const tagsResult = await api.datastore.getAddressTags(addressId);
7373+ if (!tagsResult.success) {
7474+ return results;
7575+ }
7676+7777+ for (const tagName of tagNames) {
7878+ // Find the tag by name
7979+ const tag = tagsResult.data.find(t => t.name.toLowerCase() === tagName.toLowerCase());
8080+ if (!tag) {
8181+ console.log('Tag not found on address:', tagName);
8282+ continue;
8383+ }
8484+8585+ // Unlink tag from address
8686+ const unlinkResult = await api.datastore.untagAddress(addressId, tag.id);
8787+ results.push({
8888+ tag,
8989+ removed: unlinkResult.removed
9090+ });
9191+ }
9292+9393+ return results;
9494+};
9595+9696+/**
9797+ * Get tags for an address
9898+ */
9999+const getTagsForAddress = async (addressId) => {
100100+ const result = await api.datastore.getAddressTags(addressId);
101101+ if (!result.success) return [];
102102+ return result.data;
103103+};
104104+105105+// Commands
106106+const commands = [
107107+ {
108108+ name: 'tag',
109109+ description: 'Add tags to the active window URL',
110110+ async execute(ctx) {
111111+ // Get active window
112112+ const activeWindow = await getActiveWindow();
113113+ if (!activeWindow) {
114114+ console.log('No active window found');
115115+ return { success: false, error: 'No active window' };
116116+ }
117117+118118+ const url = activeWindow.url;
119119+ console.log('Tagging URL:', url);
120120+121121+ // Find address in datastore
122122+ let address = await findAddressByUri(url);
123123+124124+ // If no address exists, create one
125125+ if (!address) {
126126+ const addResult = await api.datastore.addAddress(url, {
127127+ title: activeWindow.title || ''
128128+ });
129129+ if (!addResult.success) {
130130+ console.error('Failed to create address:', addResult.error);
131131+ return { success: false, error: 'Failed to create address' };
132132+ }
133133+ address = { id: addResult.id };
134134+ }
135135+136136+ // No args - show current tags
137137+ if (!ctx.search) {
138138+ const tags = await getTagsForAddress(address.id);
139139+ if (tags.length === 0) {
140140+ console.log('No tags for:', url);
141141+ } else {
142142+ console.log('Tags for', url + ':');
143143+ tags.forEach(t => console.log(' -', t.name, `(frecency: ${t.frecencyScore?.toFixed(1) || 0})`));
144144+ }
145145+ return { success: true, tags };
146146+ }
147147+148148+ // Parse args
149149+ const args = ctx.search.trim().split(/\s+/);
150150+ const removeMode = args[0] === '-r';
151151+ const tagsToProcess = removeMode ? args.slice(1) : args;
152152+153153+ if (tagsToProcess.length === 0) {
154154+ console.log('No tags specified');
155155+ return { success: false, error: 'No tags specified' };
156156+ }
157157+158158+ // Add or remove tags
159159+ if (removeMode) {
160160+ const results = await removeTagsFromAddress(address.id, tagsToProcess);
161161+ const removed = results.filter(r => r.removed).map(r => r.tag.name);
162162+ if (removed.length > 0) {
163163+ console.log('Removed tags:', removed.join(', '), 'from', url);
164164+ }
165165+ return { success: true, removed };
166166+ } else {
167167+ const results = await addTagsToAddress(address.id, tagsToProcess);
168168+ const added = results.filter(r => !r.alreadyExists).map(r => r.tag.name);
169169+ const existing = results.filter(r => r.alreadyExists).map(r => r.tag.name);
170170+ if (added.length > 0) {
171171+ console.log('Added tags:', added.join(', '), 'to', url);
172172+ }
173173+ if (existing.length > 0) {
174174+ console.log('Already tagged:', existing.join(', '));
175175+ }
176176+ return { success: true, added, existing };
177177+ }
178178+ }
179179+ },
180180+ {
181181+ name: 'tags',
182182+ description: 'Show tags for the active window URL (or all tags by frecency)',
183183+ async execute(ctx) {
184184+ // If search term provided, show all tags matching
185185+ if (ctx.search) {
186186+ const result = await api.datastore.getTagsByFrecency();
187187+ if (!result.success) {
188188+ console.log('Failed to get tags');
189189+ return { success: false };
190190+ }
191191+192192+ const filter = ctx.search.toLowerCase();
193193+ const filtered = result.data.filter(t => t.name.toLowerCase().includes(filter));
194194+195195+ if (filtered.length === 0) {
196196+ console.log('No tags matching:', ctx.search);
197197+ } else {
198198+ console.log('Tags matching "' + ctx.search + '":');
199199+ filtered.forEach(t => {
200200+ console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`);
201201+ });
202202+ }
203203+ return { success: true, tags: filtered };
204204+ }
205205+206206+ // No args - show tags for active window
207207+ const activeWindow = await getActiveWindow();
208208+ if (!activeWindow) {
209209+ // No active window - show all tags by frecency
210210+ const result = await api.datastore.getTagsByFrecency();
211211+ if (!result.success) {
212212+ console.log('Failed to get tags');
213213+ return { success: false };
214214+ }
215215+216216+ if (result.data.length === 0) {
217217+ console.log('No tags yet');
218218+ } else {
219219+ console.log('All tags (by frecency):');
220220+ result.data.slice(0, 20).forEach(t => {
221221+ console.log(' -', t.name, `(used ${t.frequency}x, frecency: ${t.frecencyScore?.toFixed(1) || 0})`);
222222+ });
223223+ }
224224+ return { success: true, tags: result.data };
225225+ }
226226+227227+ const address = await findAddressByUri(activeWindow.url);
228228+ if (!address) {
229229+ console.log('No tags for:', activeWindow.url);
230230+ return { success: true, tags: [] };
231231+ }
232232+233233+ const tags = await getTagsForAddress(address.id);
234234+235235+ if (tags.length === 0) {
236236+ console.log('No tags for:', activeWindow.url);
237237+ } else {
238238+ console.log('Tags for', activeWindow.url + ':');
239239+ tags.forEach(t => console.log(' -', t.name));
240240+ }
241241+242242+ return { success: true, tags };
243243+ }
244244+ },
245245+ {
246246+ name: 'untag',
247247+ description: 'Remove tags from the active window URL',
248248+ async execute(ctx) {
249249+ if (!ctx.search) {
250250+ console.log('Usage: untag <tag1> [tag2] ...');
251251+ return { success: false, error: 'No tags specified' };
252252+ }
253253+254254+ // Delegate to tag -r
255255+ return commands[0].execute({ search: '-r ' + ctx.search });
256256+ }
257257+ }
258258+];
259259+260260+export default {
261261+ commands
262262+};