@upgraide/ui-notes-cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import type { ResolvedNote, FileMatch } from '../resolvers/file-resolver.js';
2
+
3
+ interface Note {
4
+ id: string;
5
+ type: string;
6
+ body: string;
7
+ url: string;
8
+ component: string | null;
9
+ selector: string | null;
10
+ tag: string | null;
11
+ text_content: string | null;
12
+ created_at: string;
13
+ }
14
+
15
+ interface FormatOptions {
16
+ format: 'markdown' | 'xml' | 'json';
17
+ componentFilter?: string;
18
+ projectName: string;
19
+ resolvedNotes?: Map<string, ResolvedNote>;
20
+ }
21
+
22
+ export function formatNotesForAI(notes: Note[], opts: FormatOptions): string {
23
+ // Filter by component if specified
24
+ let filtered = notes;
25
+ if (opts.componentFilter) {
26
+ filtered = notes.filter(n =>
27
+ n.component?.toLowerCase().includes(opts.componentFilter!.toLowerCase())
28
+ );
29
+ }
30
+
31
+ // Group by component
32
+ const grouped = new Map<string, Note[]>();
33
+ for (const note of filtered) {
34
+ const key = note.component || '_unresolved';
35
+ if (!grouped.has(key)) grouped.set(key, []);
36
+ grouped.get(key)!.push(note);
37
+ }
38
+
39
+ const date = new Date().toISOString().split('T')[0];
40
+
41
+ if (opts.format === 'xml') {
42
+ return formatAsXML(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
43
+ }
44
+ if (opts.format === 'json') {
45
+ return formatAsJSON(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
46
+ }
47
+ return formatAsMarkdown(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
48
+ }
49
+
50
+ function formatFileMatch(file: FileMatch): string {
51
+ const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
52
+ return `\`${loc}\` (${file.confidence} confidence, matched on ${file.matchedOn})`;
53
+ }
54
+
55
+ function formatAsMarkdown(
56
+ grouped: Map<string, Note[]>,
57
+ total: number,
58
+ projectName: string,
59
+ date: string,
60
+ resolvedNotes?: Map<string, ResolvedNote>,
61
+ ): string {
62
+ const lines: string[] = [];
63
+
64
+ lines.push(`# UI Notes: ${projectName}`);
65
+
66
+ if (total === 0) {
67
+ lines.push('> No open notes.');
68
+ return lines.join('\n') + '\n';
69
+ }
70
+
71
+ const resolveInfo = resolvedNotes ? ' | File resolution: enabled' : '';
72
+ lines.push(`> ${total} open note${total === 1 ? '' : 's'} as of ${date}${resolveInfo}`);
73
+ lines.push('');
74
+
75
+ for (const [component, notes] of grouped) {
76
+ if (component === '_unresolved') {
77
+ lines.push(`## _Unresolved (${notes.length} note${notes.length === 1 ? '' : 's'})`);
78
+ } else {
79
+ lines.push(`## Component: ${component} (${notes.length} note${notes.length === 1 ? '' : 's'})`);
80
+ }
81
+ lines.push('');
82
+
83
+ for (const note of notes) {
84
+ const typeLabel = (note.type || 'note').toUpperCase();
85
+ const firstLine = note.body?.split('\n')[0] || '(no body)';
86
+ lines.push(`### [${typeLabel}] ${firstLine}`);
87
+
88
+ // File resolution output
89
+ if (resolvedNotes) {
90
+ const resolved = resolvedNotes.get(note.id);
91
+ if (resolved && resolved.files.length > 0) {
92
+ for (const file of resolved.files) {
93
+ lines.push(`- **File**: ${formatFileMatch(file)}`);
94
+ }
95
+ } else {
96
+ lines.push(`- **File**: _(no match found)_`);
97
+ }
98
+ }
99
+
100
+ if (note.selector) {
101
+ lines.push(`- **Selector**: \`${note.selector}\``);
102
+ }
103
+ if (note.url) {
104
+ lines.push(`- **URL**: ${note.url}`);
105
+ }
106
+ if (note.tag || note.text_content) {
107
+ const parts: string[] = [];
108
+ if (note.tag) parts.push(`<${note.tag}>`);
109
+ if (note.text_content) parts.push(`"${note.text_content}"`);
110
+ lines.push(`- **Element**: ${parts.join(' ')}`);
111
+ }
112
+ if (note.created_at) {
113
+ const reported = note.created_at.split('T')[0];
114
+ lines.push(`- **Reported**: ${reported}`);
115
+ }
116
+
117
+ // Full body as blockquote
118
+ if (note.body) {
119
+ const bodyLines = note.body.split('\n').map(l => `> ${l}`);
120
+ lines.push(bodyLines.join('\n'));
121
+ }
122
+
123
+ lines.push('');
124
+ }
125
+ }
126
+
127
+ return lines.join('\n');
128
+ }
129
+
130
+ function formatAsXML(
131
+ grouped: Map<string, Note[]>,
132
+ total: number,
133
+ projectName: string,
134
+ date: string,
135
+ resolvedNotes?: Map<string, ResolvedNote>,
136
+ ): string {
137
+ const lines: string[] = [];
138
+
139
+ lines.push(`<ui-notes project="${escapeXml(projectName)}" count="${total}" date="${date}">`);
140
+
141
+ for (const [component, notes] of grouped) {
142
+ const compName = component === '_unresolved' ? '_unresolved' : component;
143
+ lines.push(` <component name="${escapeXml(compName)}" count="${notes.length}">`);
144
+
145
+ for (const note of notes) {
146
+ const typeAttr = (note.type || 'note').toLowerCase();
147
+ lines.push(` <note type="${escapeXml(typeAttr)}" id="${escapeXml(note.id)}">`);
148
+ lines.push(` <body>${escapeXml(note.body || '')}</body>`);
149
+
150
+ // File resolution output
151
+ if (resolvedNotes) {
152
+ const resolved = resolvedNotes.get(note.id);
153
+ if (resolved && resolved.files.length > 0) {
154
+ lines.push(` <resolved-files>`);
155
+ for (const file of resolved.files) {
156
+ const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
157
+ lines.push(` <file path="${escapeXml(loc)}" confidence="${file.confidence}" match-type="${file.matchType}" matched-on="${escapeXml(file.matchedOn)}" />`);
158
+ }
159
+ lines.push(` </resolved-files>`);
160
+ }
161
+ }
162
+
163
+ if (note.selector) {
164
+ lines.push(` <selector>${escapeXml(note.selector)}</selector>`);
165
+ }
166
+ if (note.url) {
167
+ lines.push(` <url>${escapeXml(note.url)}</url>`);
168
+ }
169
+ if (note.tag) {
170
+ lines.push(` <tag>${escapeXml(note.tag)}</tag>`);
171
+ }
172
+ if (note.text_content) {
173
+ lines.push(` <text-content>${escapeXml(note.text_content)}</text-content>`);
174
+ }
175
+ if (note.created_at) {
176
+ lines.push(` <created>${note.created_at.split('T')[0]}</created>`);
177
+ }
178
+ lines.push(` </note>`);
179
+ }
180
+
181
+ lines.push(` </component>`);
182
+ }
183
+
184
+ lines.push(`</ui-notes>`);
185
+ return lines.join('\n') + '\n';
186
+ }
187
+
188
+ function formatAsJSON(
189
+ grouped: Map<string, Note[]>,
190
+ total: number,
191
+ projectName: string,
192
+ date: string,
193
+ resolvedNotes?: Map<string, ResolvedNote>,
194
+ ): string {
195
+ const components: Record<string, unknown[]> = {};
196
+
197
+ for (const [component, notes] of grouped) {
198
+ components[component] = notes.map(n => {
199
+ const entry: Record<string, unknown> = {
200
+ id: n.id,
201
+ type: n.type,
202
+ body: n.body,
203
+ selector: n.selector || undefined,
204
+ url: n.url || undefined,
205
+ tag: n.tag || undefined,
206
+ text_content: n.text_content || undefined,
207
+ created_at: n.created_at,
208
+ };
209
+
210
+ if (resolvedNotes) {
211
+ const resolved = resolvedNotes.get(n.id);
212
+ entry.resolved_files = resolved?.files || [];
213
+ }
214
+
215
+ return entry;
216
+ });
217
+ }
218
+
219
+ const output = {
220
+ project: projectName,
221
+ total,
222
+ date,
223
+ components,
224
+ };
225
+
226
+ return JSON.stringify(output, null, 2) + '\n';
227
+ }
228
+
229
+ function escapeXml(str: string): string {
230
+ return str
231
+ .replace(/&/g, '&amp;')
232
+ .replace(/</g, '&lt;')
233
+ .replace(/>/g, '&gt;')
234
+ .replace(/"/g, '&quot;')
235
+ .replace(/'/g, '&apos;');
236
+ }
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import { getConfig } from './config.js';
4
4
  import { configShow, configSet } from './commands/config.js';
@@ -6,6 +6,8 @@ import { listProjects, createProject, updateProject, deleteProject, getProject,
6
6
  import { pull } from './commands/pull.js';
7
7
  import { resolve, wontfix } from './commands/resolve.js';
8
8
  import { login } from './commands/login.js';
9
+ import { comment, listComments } from './commands/comment.js';
10
+ import { context } from './commands/context.js';
9
11
 
10
12
  const HELP = `uinotes - CLI for UI Notes
11
13
 
@@ -22,15 +24,30 @@ Commands:
22
24
  projects delete <slug> Archive a project
23
25
  login Login and create API key
24
26
  pull Fetch open notes
27
+ context AI-optimized digest of open notes
25
28
  resolve <id> Mark note as resolved
26
29
  wontfix <id> Mark note as won't fix
30
+ comment <id> "text" Add a comment to a note
31
+ comments <id> List comments for a note
32
+ resolve-by-component <name> [msg] Bulk resolve notes by component
33
+ resolve-by-selector <sel> [msg] Bulk resolve notes by selector
34
+ visual-diff Capture & compare page screenshots
27
35
  config View/edit configuration
28
36
 
29
37
  Options:
30
38
  --help, -h Show help
31
39
  --project <name> Override project for this request
32
40
  --archived Include archived projects in list
33
- --url <pattern> URL pattern for project create (repeatable)`;
41
+ --url <pattern> URL pattern for project create (repeatable)
42
+ --format <fmt> Output format for context: markdown (default), xml, json
43
+ --component <n> Filter notes by component name (context command)
44
+ --status <s> Filter notes by status (default: open)
45
+ --type <t> Filter notes by type
46
+ --resolve-files Resolve notes to source files (pull, context)
47
+ --root-dir <dir> Source root for file resolution (default: cwd)
48
+ --compare Compare current screenshots against previous (visual-diff)
49
+ --width <px> Viewport width for screenshots (default: 1280)
50
+ --full-page Capture full page height (default: true)`;
34
51
 
35
52
  function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
36
53
  const flags: Record<string, string> = {};
@@ -195,6 +212,21 @@ async function main() {
195
212
  status: flags.status,
196
213
  type: flags.type,
197
214
  limit: flags.limit,
215
+ resolveFiles: flags['resolve-files'] === 'true',
216
+ rootDir: flags['root-dir'],
217
+ });
218
+ break;
219
+
220
+ case 'context':
221
+ await context(config, {
222
+ project,
223
+ type: flags.type,
224
+ status: flags.status,
225
+ component: flags.component,
226
+ format: (flags.format as 'markdown' | 'xml' | 'json') || undefined,
227
+ limit: flags.limit,
228
+ resolveFiles: flags['resolve-files'] === 'true',
229
+ rootDir: flags['root-dir'],
198
230
  });
199
231
  break;
200
232
 
@@ -218,6 +250,73 @@ async function main() {
218
250
  break;
219
251
  }
220
252
 
253
+ case 'comment': {
254
+ const id = positional[1];
255
+ const body = positional.slice(2).join(' ');
256
+ if (!id || !body) {
257
+ console.error('Usage: uinotes comment <note-id> "comment text"');
258
+ process.exit(1);
259
+ }
260
+ await comment(config, id, body, project);
261
+ break;
262
+ }
263
+
264
+ case 'comments': {
265
+ const id = positional[1];
266
+ if (!id) {
267
+ console.error('Usage: uinotes comments <note-id>');
268
+ process.exit(1);
269
+ }
270
+ await listComments(config, id, project);
271
+ break;
272
+ }
273
+
274
+ case 'resolve-by-component': {
275
+ const name = positional[1];
276
+ if (!name) {
277
+ console.error('Usage: uinotes resolve-by-component <component-name> [--message "text"]');
278
+ process.exit(1);
279
+ }
280
+ const { resolveByComponent } = await import('./commands/bulk-resolve.js');
281
+ await resolveByComponent(config, name, {
282
+ project,
283
+ message: flags.message || positional.slice(2).join(' ') || undefined,
284
+ dryRun: flags['dry-run'] === 'true',
285
+ force: flags.force === 'true',
286
+ status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
287
+ });
288
+ break;
289
+ }
290
+
291
+ case 'visual-diff': {
292
+ const { visualDiff } = await import('./commands/visual-diff.js');
293
+ await visualDiff(config, {
294
+ project,
295
+ url: flags.url,
296
+ compare: flags.compare === 'true',
297
+ width: flags.width ? parseInt(flags.width) : undefined,
298
+ fullPage: flags['full-page'] !== 'false',
299
+ });
300
+ break;
301
+ }
302
+
303
+ case 'resolve-by-selector': {
304
+ const sel = positional[1];
305
+ if (!sel) {
306
+ console.error('Usage: uinotes resolve-by-selector <selector> [--message "text"]');
307
+ process.exit(1);
308
+ }
309
+ const { resolveBySelector } = await import('./commands/bulk-resolve.js');
310
+ await resolveBySelector(config, sel, {
311
+ project,
312
+ message: flags.message || positional.slice(2).join(' ') || undefined,
313
+ dryRun: flags['dry-run'] === 'true',
314
+ force: flags.force === 'true',
315
+ status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
316
+ });
317
+ break;
318
+ }
319
+
221
320
  default:
222
321
  console.error(`Unknown command: ${command}`);
223
322
  console.error('Run uinotes --help for usage');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upgraide/ui-notes-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for UI Notes - pull and manage UI feedback notes",
5
5
  "license": "MIT",
6
6
  "keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],
@@ -9,7 +9,9 @@
9
9
  },
10
10
  "files": [
11
11
  "*.ts",
12
- "commands/*.ts"
12
+ "commands/*.ts",
13
+ "formatters/*.ts",
14
+ "resolvers/*.ts"
13
15
  ],
14
16
  "engines": {
15
17
  "bun": ">=1.0.0"