@upgraide/ui-notes-cli 0.1.2 → 0.2.0

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,116 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+ import { readFileSync } from 'fs';
4
+
5
+ interface BatchOp {
6
+ op: 'resolve' | 'wontfix' | 'comment' | 'reopen';
7
+ id: string;
8
+ message?: string;
9
+ text?: string;
10
+ }
11
+
12
+ interface BatchOptions {
13
+ project?: string;
14
+ concurrency?: number;
15
+ dryRun?: boolean;
16
+ }
17
+
18
+ export async function batch(config: Config, source: string, opts: BatchOptions): Promise<void> {
19
+ let input: string;
20
+ if (source === '-') {
21
+ input = await readStdin();
22
+ } else {
23
+ input = readFileSync(source, 'utf-8');
24
+ }
25
+
26
+ const lines = input.trim().split('\n').filter(l => l.trim());
27
+ const ops: BatchOp[] = [];
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ try {
31
+ ops.push(JSON.parse(lines[i]));
32
+ } catch {
33
+ console.error(`Line ${i + 1}: invalid JSON — skipped`);
34
+ }
35
+ }
36
+
37
+ if (ops.length === 0) {
38
+ console.log('No operations to execute.');
39
+ return;
40
+ }
41
+
42
+ if (opts.dryRun) {
43
+ console.log(`Dry run: ${ops.length} operations`);
44
+ for (let i = 0; i < ops.length; i++) {
45
+ console.log(`[${i + 1}/${ops.length}] ${ops[i].op} ${ops[i].id}`);
46
+ }
47
+ return;
48
+ }
49
+
50
+ const concurrency = opts.concurrency || 5;
51
+ let succeeded = 0;
52
+ let failed = 0;
53
+
54
+ for (let i = 0; i < ops.length; i += concurrency) {
55
+ const chunk = ops.slice(i, i + concurrency);
56
+ await Promise.allSettled(
57
+ chunk.map(async (op, idx) => {
58
+ const globalIdx = i + idx;
59
+ try {
60
+ await executeOp(config, op, opts.project);
61
+ succeeded++;
62
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✓`);
63
+ } catch (err: any) {
64
+ failed++;
65
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✗ ${err.message}`);
66
+ }
67
+ })
68
+ );
69
+ }
70
+
71
+ console.log(`Batch complete: ${succeeded}/${ops.length} succeeded${failed > 0 ? `, ${failed} failed` : ''}`);
72
+ }
73
+
74
+ async function executeOp(config: Config, op: BatchOp, project?: string): Promise<void> {
75
+ switch (op.op) {
76
+ case 'resolve':
77
+ await apiFetch(config, `/notes/${op.id}`, {
78
+ method: 'PATCH',
79
+ body: { status: 'resolved', resolution: op.message },
80
+ project,
81
+ });
82
+ break;
83
+ case 'wontfix':
84
+ await apiFetch(config, `/notes/${op.id}`, {
85
+ method: 'PATCH',
86
+ body: { status: 'wontfix', resolution: op.message },
87
+ project,
88
+ });
89
+ break;
90
+ case 'comment':
91
+ if (!op.text) throw new Error('Missing "text" field for comment operation');
92
+ await apiFetch(config, `/notes/${op.id}/comments`, {
93
+ method: 'POST',
94
+ body: { body: op.text },
95
+ project,
96
+ });
97
+ break;
98
+ case 'reopen':
99
+ await apiFetch(config, `/notes/${op.id}`, {
100
+ method: 'PATCH',
101
+ body: { status: 'open' },
102
+ project,
103
+ });
104
+ break;
105
+ default:
106
+ throw new Error(`Unknown operation: ${(op as any).op}`);
107
+ }
108
+ }
109
+
110
+ async function readStdin(): Promise<string> {
111
+ const chunks: Buffer[] = [];
112
+ for await (const chunk of process.stdin) {
113
+ chunks.push(Buffer.from(chunk));
114
+ }
115
+ return Buffer.concat(chunks).toString('utf-8');
116
+ }
@@ -0,0 +1,150 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ChangelogOptions {
5
+ project?: string;
6
+ since?: string;
7
+ format?: 'markdown' | 'json' | 'conventional';
8
+ }
9
+
10
+ interface Note {
11
+ id: string;
12
+ type: string;
13
+ body: string;
14
+ component: string | null;
15
+ resolved_at: string | null;
16
+ }
17
+
18
+ const TYPE_ORDER = ['bug', 'ux', 'feature', 'question'];
19
+
20
+ const TYPE_LABELS: Record<string, string> = {
21
+ bug: 'Bugs',
22
+ ux: 'UX',
23
+ feature: 'Features',
24
+ question: 'Questions',
25
+ };
26
+
27
+ const TYPE_CONVENTIONAL: Record<string, string> = {
28
+ bug: 'fix',
29
+ ux: 'style',
30
+ feature: 'feat',
31
+ question: 'docs',
32
+ };
33
+
34
+ function isISODate(value: string): boolean {
35
+ return /^\d{4}-\d{2}-\d{2}/.test(value);
36
+ }
37
+
38
+ function resolveTimestamp(since: string): string {
39
+ if (isISODate(since)) {
40
+ return since;
41
+ }
42
+
43
+ // Treat as git ref, resolve to ISO timestamp
44
+ const result = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', since]);
45
+ const output = result.stdout.toString().trim();
46
+
47
+ if (result.exitCode !== 0 || !output) {
48
+ throw new Error(`Could not resolve git ref "${since}". Make sure it exists.`);
49
+ }
50
+
51
+ return output;
52
+ }
53
+
54
+ function formatMarkdown(notes: Note[]): string {
55
+ const grouped: Record<string, Note[]> = {};
56
+
57
+ for (const note of notes) {
58
+ if (!grouped[note.type]) grouped[note.type] = [];
59
+ grouped[note.type].push(note);
60
+ }
61
+
62
+ const lines: string[] = ['## UI Notes Resolved', ''];
63
+
64
+ for (const type of TYPE_ORDER) {
65
+ const group = grouped[type];
66
+ if (!group || group.length === 0) continue;
67
+
68
+ lines.push(`### ${TYPE_LABELS[type] || type}`);
69
+ for (const note of group) {
70
+ const shortId = note.id.slice(0, 8);
71
+ const body = note.body.split('\n')[0].trim();
72
+ lines.push(`- ${body} (#${shortId})`);
73
+ }
74
+ lines.push('');
75
+ }
76
+
77
+ return lines.join('\n');
78
+ }
79
+
80
+ function formatConventional(notes: Note[]): string {
81
+ const lines: string[] = [];
82
+
83
+ for (const type of TYPE_ORDER) {
84
+ const group = notes.filter((n) => n.type === type);
85
+ for (const note of group) {
86
+ const prefix = TYPE_CONVENTIONAL[note.type] || note.type;
87
+ const scope = note.component || 'ui';
88
+ const body = note.body.split('\n')[0].trim();
89
+ const shortId = note.id.slice(0, 8);
90
+ lines.push(`${prefix}(${scope}): ${body} (note:${shortId})`);
91
+ }
92
+ }
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ function formatJSON(notes: Note[]): string {
98
+ const items = notes.map((n) => ({
99
+ id: n.id,
100
+ type: n.type,
101
+ body: n.body,
102
+ component: n.component,
103
+ resolved_at: n.resolved_at,
104
+ }));
105
+ return JSON.stringify(items, null, 2);
106
+ }
107
+
108
+ export async function changelog(config: Config, opts: ChangelogOptions): Promise<void> {
109
+ const since = opts.since;
110
+ if (!since) {
111
+ console.error('--since is required. Provide a git ref (e.g., HEAD~5, v1.2.0) or ISO date (e.g., 2026-02-01).');
112
+ process.exit(1);
113
+ }
114
+
115
+ const timestamp = resolveTimestamp(since);
116
+ const format = opts.format || 'markdown';
117
+
118
+ const params = new URLSearchParams();
119
+ params.set('status', 'all');
120
+ params.set('resolved_after', timestamp);
121
+ params.set('limit', '500');
122
+
123
+ const path = `/notes?${params.toString()}`;
124
+ const response = (await apiFetch(config, path, { project: opts.project })) as {
125
+ ok: boolean;
126
+ data: { notes: Note[] };
127
+ };
128
+
129
+ const notes = response.data.notes
130
+ .filter((n) => n.resolved_at)
131
+ .sort((a, b) => (a.resolved_at! > b.resolved_at! ? 1 : -1));
132
+
133
+ if (notes.length === 0) {
134
+ console.log('No resolved notes found since ' + timestamp);
135
+ return;
136
+ }
137
+
138
+ switch (format) {
139
+ case 'json':
140
+ console.log(formatJSON(notes));
141
+ break;
142
+ case 'conventional':
143
+ console.log(formatConventional(notes));
144
+ break;
145
+ case 'markdown':
146
+ default:
147
+ console.log(formatMarkdown(notes));
148
+ break;
149
+ }
150
+ }
@@ -0,0 +1,67 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ExplainOptions {
5
+ format?: 'markdown' | 'json';
6
+ project?: string;
7
+ }
8
+
9
+ export async function explain(config: Config, noteId: string, opts: ExplainOptions): Promise<void> {
10
+ const response = await apiFetch(config, `/notes/${noteId}/explain`, {
11
+ project: opts.project,
12
+ }) as any;
13
+
14
+ const { note, comments, history, visual_diffs } = response.data;
15
+
16
+ if (opts.format === 'json') {
17
+ console.log(JSON.stringify(response.data, null, 2));
18
+ return;
19
+ }
20
+
21
+ // Default: markdown format
22
+ const lines: string[] = [];
23
+ lines.push(`# Note ${note.id} — ${note.type} (${note.status})`);
24
+ lines.push('');
25
+ if (note.component) lines.push(`**Component**: ${note.component}`);
26
+ if (note.selector) lines.push(`**Selector**: \`${note.selector}\``);
27
+ lines.push(`**URL**: ${note.url}`);
28
+ if (note.author) lines.push(`**Author**: ${note.author}`);
29
+ lines.push(`**Created**: ${note.created_at}`);
30
+ if (note.resolved_at) lines.push(`**Resolved**: ${note.resolved_at}`);
31
+ if (note.screenshot_url) lines.push(`**Screenshot**: ${note.screenshot_url}`);
32
+ lines.push('');
33
+ lines.push(`> ${note.body}`);
34
+
35
+ if (comments.length > 0) {
36
+ lines.push('');
37
+ lines.push(`## Comments (${comments.length})`);
38
+ lines.push('');
39
+ for (const c of comments) {
40
+ lines.push(`**${c.author}** (${c.created_at}):`);
41
+ lines.push(`> ${c.body}`);
42
+ lines.push('');
43
+ }
44
+ }
45
+
46
+ if (history.length > 0) {
47
+ lines.push('## History');
48
+ lines.push('');
49
+ for (const h of history) {
50
+ lines.push(`- ${h.changed_at}: ${h.field} ${h.old_value ?? '(none)'} → ${h.new_value ?? '(none)'}${h.changed_by ? ` (${h.changed_by})` : ''}`);
51
+ }
52
+ }
53
+
54
+ if (visual_diffs.length > 0) {
55
+ lines.push('');
56
+ lines.push(`## Visual Diffs (${visual_diffs.length})`);
57
+ lines.push('');
58
+ for (const vd of visual_diffs) {
59
+ lines.push(`- ${vd.created_at}: diff ${vd.diff_percentage != null ? `${vd.diff_percentage}%` : 'N/A'}`);
60
+ if (vd.before_url) lines.push(` Before: ${vd.before_url}`);
61
+ if (vd.after_url) lines.push(` After: ${vd.after_url}`);
62
+ if (vd.diff_url) lines.push(` Diff: ${vd.diff_url}`);
63
+ }
64
+ }
65
+
66
+ console.log(lines.join('\n'));
67
+ }
@@ -0,0 +1,33 @@
1
+ import type { Config } from '../config.js';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const RESOURCES = ['notes', 'comments', 'projects', 'webhooks'];
6
+
7
+ export async function schema(_config: Config, resource?: string): Promise<void> {
8
+ if (!resource) {
9
+ console.log('Available schemas:');
10
+ for (const r of RESOURCES) {
11
+ console.log(` ${r}`);
12
+ }
13
+ console.log('\nUsage: uinotes schema --resource <name>');
14
+ return;
15
+ }
16
+
17
+ if (!RESOURCES.includes(resource)) {
18
+ console.error(`Unknown resource: ${resource}`);
19
+ console.error(`Available: ${RESOURCES.join(', ')}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const schemaDir = join(import.meta.dir, '..', 'schemas');
24
+ const schemaPath = join(schemaDir, `${resource}.json`);
25
+
26
+ try {
27
+ const content = readFileSync(schemaPath, 'utf-8');
28
+ console.log(content);
29
+ } catch {
30
+ console.error(`Schema file not found: ${schemaPath}`);
31
+ process.exit(1);
32
+ }
33
+ }
@@ -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
@@ -25,13 +25,17 @@ Commands:
25
25
  login Login and create API key
26
26
  pull Fetch open notes
27
27
  context AI-optimized digest of open notes
28
+ explain <id> Full context for a single note
28
29
  resolve <id> Mark note as resolved
29
30
  wontfix <id> Mark note as won't fix
30
31
  comment <id> "text" Add a comment to a note
31
32
  comments <id> List comments for a note
32
33
  resolve-by-component <name> [msg] Bulk resolve notes by component
33
34
  resolve-by-selector <sel> [msg] Bulk resolve notes by selector
35
+ changelog Generate changelog from resolved notes
34
36
  visual-diff Capture & compare page screenshots
37
+ batch <file|-> Execute NDJSON batch operations
38
+ schema List or print API resource schemas
35
39
  config View/edit configuration
36
40
 
37
41
  Options:
@@ -47,7 +51,11 @@ Options:
47
51
  --root-dir <dir> Source root for file resolution (default: cwd)
48
52
  --compare Compare current screenshots against previous (visual-diff)
49
53
  --width <px> Viewport width for screenshots (default: 1280)
50
- --full-page Capture full page height (default: true)`;
54
+ --full-page Capture full page height (default: true)
55
+ --since <ref> Git ref or ISO date for changelog start
56
+ --resource <name> Resource name for schema command
57
+ --concurrency <n> Concurrent batch operations (default: 5)
58
+ --dry-run Preview batch operations without executing`;
51
59
 
52
60
  function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
53
61
  const flags: Record<string, string> = {};
@@ -116,6 +124,13 @@ async function main() {
116
124
  return;
117
125
  }
118
126
 
127
+ // Schema command is local-only, no API key needed
128
+ if (command === 'schema') {
129
+ const { schema } = await import('./commands/schema.js');
130
+ await schema(getConfig(), flags.resource);
131
+ return;
132
+ }
133
+
119
134
  const config = getConfig();
120
135
  if (!config.apiKey) {
121
136
  console.error('No API key configured. Run: uinotes config set apiKey <your-key>');
@@ -230,6 +245,20 @@ async function main() {
230
245
  });
231
246
  break;
232
247
 
248
+ case 'explain': {
249
+ const id = positional[1];
250
+ if (!id) {
251
+ console.error('Usage: uinotes explain <note-id> [--format markdown|json]');
252
+ process.exit(1);
253
+ }
254
+ const { explain } = await import('./commands/explain.js');
255
+ await explain(config, id, {
256
+ project,
257
+ format: (flags.format as 'markdown' | 'json') || undefined,
258
+ });
259
+ break;
260
+ }
261
+
233
262
  case 'resolve': {
234
263
  const id = positional[1];
235
264
  if (!id) {
@@ -288,6 +317,16 @@ async function main() {
288
317
  break;
289
318
  }
290
319
 
320
+ case 'changelog': {
321
+ const { changelog } = await import('./commands/changelog.js');
322
+ await changelog(config, {
323
+ project,
324
+ since: flags.since,
325
+ format: (flags.format as 'markdown' | 'json' | 'conventional') || undefined,
326
+ });
327
+ break;
328
+ }
329
+
291
330
  case 'visual-diff': {
292
331
  const { visualDiff } = await import('./commands/visual-diff.js');
293
332
  await visualDiff(config, {
@@ -300,6 +339,21 @@ async function main() {
300
339
  break;
301
340
  }
302
341
 
342
+ case 'batch': {
343
+ const source = positional[1];
344
+ if (!source) {
345
+ console.error('Usage: uinotes batch <file|-> [--concurrency 5] [--dry-run]');
346
+ process.exit(1);
347
+ }
348
+ const { batch } = await import('./commands/batch.js');
349
+ await batch(config, source, {
350
+ project,
351
+ concurrency: flags.concurrency ? parseInt(flags.concurrency) : undefined,
352
+ dryRun: flags['dry-run'] === 'true',
353
+ });
354
+ break;
355
+ }
356
+
303
357
  case 'resolve-by-selector': {
304
358
  const sel = positional[1];
305
359
  if (!sel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upgraide/ui-notes-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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,10 @@
9
9
  },
10
10
  "files": [
11
11
  "*.ts",
12
- "commands/*.ts"
12
+ "commands/*.ts",
13
+ "formatters/*.ts",
14
+ "resolvers/*.ts",
15
+ "schemas/*.json"
13
16
  ],
14
17
  "engines": {
15
18
  "bun": ">=1.0.0"
@@ -0,0 +1,477 @@
1
+ /**
2
+ * File resolver: maps UI note metadata (component names, CSS selectors) to source files.
3
+ *
4
+ * Resolution strategies in priority order:
5
+ * 1. data-testid / data-component attribute match (very high confidence)
6
+ * 2. Component name -> filename match (high confidence)
7
+ * 3. Component name -> export/function declaration (high confidence)
8
+ * 4. Semantic class name search (non-Tailwind) (high confidence)
9
+ * 5. ID attribute search (high confidence)
10
+ */
11
+
12
+ import { readdir, readFile, stat } from 'fs/promises';
13
+ import { join, relative, basename, extname } from 'path';
14
+ import { existsSync } from 'fs';
15
+ import {
16
+ parseSelector,
17
+ filterSemanticClasses,
18
+ type SelectorTokens,
19
+ } from './selector-parser.js';
20
+
21
+ export interface FileMatch {
22
+ filePath: string;
23
+ lineNumber: number | null;
24
+ matchType: 'data-attr' | 'component' | 'selector' | 'id' | 'tag';
25
+ confidence: 'high' | 'medium' | 'low';
26
+ matchedOn: string;
27
+ }
28
+
29
+ export interface ResolvedNote {
30
+ noteId: string;
31
+ files: FileMatch[];
32
+ }
33
+
34
+ export interface ResolveOptions {
35
+ rootDir: string;
36
+ extensions?: string[];
37
+ excludeDirs?: string[];
38
+ }
39
+
40
+ const DEFAULT_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte'];
41
+ const DEFAULT_EXCLUDE_DIRS = [
42
+ 'node_modules',
43
+ '.next',
44
+ 'dist',
45
+ 'build',
46
+ '.git',
47
+ 'coverage',
48
+ ];
49
+
50
+ interface NoteForResolution {
51
+ id: string;
52
+ component?: string | null;
53
+ selector?: string | null;
54
+ tag?: string | null;
55
+ }
56
+
57
+ /**
58
+ * Check whether the project uses Tailwind CSS by looking for config files.
59
+ */
60
+ function detectTailwind(rootDir: string): boolean {
61
+ const configFiles = [
62
+ 'tailwind.config.js',
63
+ 'tailwind.config.ts',
64
+ 'tailwind.config.mjs',
65
+ 'tailwind.config.cjs',
66
+ ];
67
+ return configFiles.some((f) => existsSync(join(rootDir, f)));
68
+ }
69
+
70
+ /**
71
+ * Recursively collect source files under rootDir, respecting extension and exclusion filters.
72
+ */
73
+ async function collectSourceFiles(
74
+ dir: string,
75
+ extensions: string[],
76
+ excludeDirs: string[],
77
+ ): Promise<string[]> {
78
+ const results: string[] = [];
79
+
80
+ let entries;
81
+ try {
82
+ entries = await readdir(dir, { withFileTypes: true });
83
+ } catch {
84
+ return results;
85
+ }
86
+
87
+ for (const entry of entries) {
88
+ const fullPath = join(dir, entry.name);
89
+
90
+ if (entry.isDirectory()) {
91
+ if (!excludeDirs.includes(entry.name)) {
92
+ const sub = await collectSourceFiles(fullPath, extensions, excludeDirs);
93
+ results.push(...sub);
94
+ }
95
+ } else if (entry.isFile()) {
96
+ const ext = extname(entry.name);
97
+ if (extensions.includes(ext)) {
98
+ results.push(fullPath);
99
+ }
100
+ }
101
+ }
102
+
103
+ return results;
104
+ }
105
+
106
+ /**
107
+ * Escape special regex characters in a string.
108
+ */
109
+ function escapeRegex(str: string): string {
110
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
111
+ }
112
+
113
+ /**
114
+ * Find the 1-based line number of the first occurrence of `pattern` in `content`.
115
+ */
116
+ function findLineNumber(content: string, pattern: RegExp): number | null {
117
+ const match = content.match(pattern);
118
+ if (!match || match.index === undefined) return null;
119
+ return content.slice(0, match.index).split('\n').length;
120
+ }
121
+
122
+ // ── Strategy: data-attribute match ───────────────────────────────────────
123
+
124
+ async function resolveByDataAttribute(
125
+ attrs: SelectorTokens['dataAttributes'],
126
+ sourceFiles: string[],
127
+ rootDir: string,
128
+ fileContents: Map<string, string>,
129
+ ): Promise<FileMatch[]> {
130
+ const matches: FileMatch[] = [];
131
+
132
+ for (const attr of attrs) {
133
+ if (!attr.value) continue;
134
+ const searchPattern = new RegExp(
135
+ `${escapeRegex(attr.name)}\\s*=\\s*["']${escapeRegex(attr.value)}["']`,
136
+ );
137
+
138
+ for (const filePath of sourceFiles) {
139
+ const content = await getFileContent(filePath, fileContents);
140
+ if (!content) continue;
141
+
142
+ const lineNumber = findLineNumber(content, searchPattern);
143
+ if (lineNumber !== null) {
144
+ matches.push({
145
+ filePath: relative(rootDir, filePath),
146
+ lineNumber,
147
+ matchType: 'data-attr',
148
+ confidence: 'high',
149
+ matchedOn: `${attr.name}="${attr.value}"`,
150
+ });
151
+ }
152
+ }
153
+
154
+ // data-testid is very high confidence; return immediately if found
155
+ if (matches.length > 0) return matches;
156
+ }
157
+
158
+ return matches;
159
+ }
160
+
161
+ // ── Strategy: Component name -> filename match ───────────────────────────
162
+
163
+ async function resolveByComponentFilename(
164
+ componentName: string,
165
+ sourceFiles: string[],
166
+ rootDir: string,
167
+ fileContents: Map<string, string>,
168
+ ): Promise<FileMatch[]> {
169
+ const matches: FileMatch[] = [];
170
+ const normalizedName = componentName.toLowerCase();
171
+
172
+ for (const filePath of sourceFiles) {
173
+ const fileName = basename(filePath)
174
+ .replace(/\.[^.]+$/, '')
175
+ .toLowerCase();
176
+
177
+ if (fileName === normalizedName) {
178
+ // Try to find the exact component declaration for a line number
179
+ const content = await getFileContent(filePath, fileContents);
180
+ let lineNumber: number | null = null;
181
+ if (content) {
182
+ lineNumber = findComponentDeclarationLine(content, componentName);
183
+ }
184
+
185
+ matches.push({
186
+ filePath: relative(rootDir, filePath),
187
+ lineNumber,
188
+ matchType: 'component',
189
+ confidence: 'high',
190
+ matchedOn: componentName,
191
+ });
192
+ }
193
+ }
194
+
195
+ return matches;
196
+ }
197
+
198
+ // ── Strategy: Component name -> export/function search ───────────────────
199
+
200
+ async function resolveByComponentDeclaration(
201
+ componentName: string,
202
+ sourceFiles: string[],
203
+ rootDir: string,
204
+ fileContents: Map<string, string>,
205
+ ): Promise<FileMatch[]> {
206
+ const matches: FileMatch[] = [];
207
+ const escaped = escapeRegex(componentName);
208
+
209
+ const patterns = [
210
+ new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
211
+ new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
212
+ new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
213
+ new RegExp(`export\\s+default\\s+${escaped}\\b`),
214
+ new RegExp(`displayName\\s*=\\s*['"]${escaped}['"]`),
215
+ ];
216
+
217
+ for (const filePath of sourceFiles) {
218
+ const content = await getFileContent(filePath, fileContents);
219
+ if (!content) continue;
220
+
221
+ for (const pattern of patterns) {
222
+ const lineNumber = findLineNumber(content, pattern);
223
+ if (lineNumber !== null) {
224
+ matches.push({
225
+ filePath: relative(rootDir, filePath),
226
+ lineNumber,
227
+ matchType: 'component',
228
+ confidence: 'high',
229
+ matchedOn: componentName,
230
+ });
231
+ break; // One match per file is enough
232
+ }
233
+ }
234
+ }
235
+
236
+ return matches;
237
+ }
238
+
239
+ // ── Strategy: Semantic class name search ─────────────────────────────────
240
+
241
+ async function resolveBySemanticClasses(
242
+ classNames: string[],
243
+ sourceFiles: string[],
244
+ rootDir: string,
245
+ fileContents: Map<string, string>,
246
+ ): Promise<FileMatch[]> {
247
+ const matches: FileMatch[] = [];
248
+
249
+ for (const cls of classNames) {
250
+ const pattern = new RegExp(escapeRegex(cls));
251
+
252
+ for (const filePath of sourceFiles) {
253
+ const content = await getFileContent(filePath, fileContents);
254
+ if (!content) continue;
255
+
256
+ const lineNumber = findLineNumber(content, pattern);
257
+ if (lineNumber !== null) {
258
+ matches.push({
259
+ filePath: relative(rootDir, filePath),
260
+ lineNumber,
261
+ matchType: 'selector',
262
+ confidence: 'high',
263
+ matchedOn: `.${cls}`,
264
+ });
265
+ }
266
+ }
267
+ }
268
+
269
+ return matches;
270
+ }
271
+
272
+ // ── Strategy: ID attribute search ────────────────────────────────────────
273
+
274
+ async function resolveByIds(
275
+ ids: string[],
276
+ sourceFiles: string[],
277
+ rootDir: string,
278
+ fileContents: Map<string, string>,
279
+ ): Promise<FileMatch[]> {
280
+ const matches: FileMatch[] = [];
281
+
282
+ for (const id of ids) {
283
+ const pattern = new RegExp(`id\\s*=\\s*["']${escapeRegex(id)}["']`);
284
+
285
+ for (const filePath of sourceFiles) {
286
+ const content = await getFileContent(filePath, fileContents);
287
+ if (!content) continue;
288
+
289
+ const lineNumber = findLineNumber(content, pattern);
290
+ if (lineNumber !== null) {
291
+ matches.push({
292
+ filePath: relative(rootDir, filePath),
293
+ lineNumber,
294
+ matchType: 'id',
295
+ confidence: 'high',
296
+ matchedOn: `#${id}`,
297
+ });
298
+ }
299
+ }
300
+ }
301
+
302
+ return matches;
303
+ }
304
+
305
+ // ── Helpers ──────────────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Read file content with caching within a single invocation.
309
+ */
310
+ async function getFileContent(
311
+ filePath: string,
312
+ cache: Map<string, string>,
313
+ ): Promise<string | null> {
314
+ if (cache.has(filePath)) return cache.get(filePath)!;
315
+ try {
316
+ const content = await readFile(filePath, 'utf-8');
317
+ cache.set(filePath, content);
318
+ return content;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Find the line number of a component function/const declaration.
326
+ */
327
+ function findComponentDeclarationLine(
328
+ content: string,
329
+ componentName: string,
330
+ ): number | null {
331
+ const escaped = escapeRegex(componentName);
332
+ const patterns = [
333
+ new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
334
+ new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
335
+ new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
336
+ new RegExp(`export\\s+default\\s+${escaped}\\b`),
337
+ ];
338
+ for (const p of patterns) {
339
+ const line = findLineNumber(content, p);
340
+ if (line !== null) return line;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Deduplicate file matches, keeping the highest-confidence match per file.
347
+ */
348
+ function deduplicateMatches(matches: FileMatch[]): FileMatch[] {
349
+ const byFile = new Map<string, FileMatch>();
350
+ const confidenceRank = { high: 3, medium: 2, low: 1 };
351
+
352
+ for (const m of matches) {
353
+ const existing = byFile.get(m.filePath);
354
+ if (
355
+ !existing ||
356
+ confidenceRank[m.confidence] > confidenceRank[existing.confidence]
357
+ ) {
358
+ byFile.set(m.filePath, m);
359
+ }
360
+ }
361
+
362
+ return Array.from(byFile.values());
363
+ }
364
+
365
+ // ── Main entry point ─────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Resolve a single note to source files.
369
+ */
370
+ export async function resolveNoteToFiles(
371
+ note: NoteForResolution,
372
+ opts: ResolveOptions,
373
+ ): Promise<ResolvedNote> {
374
+ const extensions = opts.extensions || DEFAULT_EXTENSIONS;
375
+ const excludeDirs = opts.excludeDirs || DEFAULT_EXCLUDE_DIRS;
376
+ const rootDir = opts.rootDir;
377
+
378
+ const sourceFiles = await collectSourceFiles(rootDir, extensions, excludeDirs);
379
+ const fileContents = new Map<string, string>();
380
+ const hasTailwind = detectTailwind(rootDir);
381
+
382
+ if (sourceFiles.length === 0) {
383
+ console.error(
384
+ `Warning: No source files found in ${rootDir}. Check --root-dir or resolve.rootDir in config.`,
385
+ );
386
+ return { noteId: note.id, files: [] };
387
+ }
388
+
389
+ let allMatches: FileMatch[] = [];
390
+
391
+ // Parse selector tokens if we have a selector
392
+ let tokens: SelectorTokens | null = null;
393
+ if (note.selector) {
394
+ tokens = parseSelector(note.selector);
395
+ }
396
+
397
+ // Strategy 1: data-attribute match (very high confidence, short-circuits)
398
+ if (tokens && tokens.dataAttributes.length > 0) {
399
+ const dataMatches = await resolveByDataAttribute(
400
+ tokens.dataAttributes,
401
+ sourceFiles,
402
+ rootDir,
403
+ fileContents,
404
+ );
405
+ if (dataMatches.length > 0) {
406
+ return { noteId: note.id, files: deduplicateMatches(dataMatches) };
407
+ }
408
+ }
409
+
410
+ // Strategy 2: Component name -> filename match
411
+ if (note.component) {
412
+ const filenameMatches = await resolveByComponentFilename(
413
+ note.component,
414
+ sourceFiles,
415
+ rootDir,
416
+ fileContents,
417
+ );
418
+ allMatches.push(...filenameMatches);
419
+ }
420
+
421
+ // Strategy 3: Component name -> export/function search (only if no filename match)
422
+ if (note.component && allMatches.length === 0) {
423
+ const declMatches = await resolveByComponentDeclaration(
424
+ note.component,
425
+ sourceFiles,
426
+ rootDir,
427
+ fileContents,
428
+ );
429
+ allMatches.push(...declMatches);
430
+ }
431
+
432
+ // If we already have high-confidence component matches, return early
433
+ if (allMatches.length > 0) {
434
+ return { noteId: note.id, files: deduplicateMatches(allMatches) };
435
+ }
436
+
437
+ // Strategy 4: Semantic class name search
438
+ if (tokens && tokens.classNames.length > 0) {
439
+ const semanticClasses = filterSemanticClasses(tokens.classNames);
440
+ if (semanticClasses.length > 0) {
441
+ const classMatches = await resolveBySemanticClasses(
442
+ semanticClasses,
443
+ sourceFiles,
444
+ rootDir,
445
+ fileContents,
446
+ );
447
+ allMatches.push(...classMatches);
448
+ }
449
+ }
450
+
451
+ // Strategy 5: ID attribute search
452
+ if (tokens && tokens.ids.length > 0) {
453
+ const idMatches = await resolveByIds(
454
+ tokens.ids,
455
+ sourceFiles,
456
+ rootDir,
457
+ fileContents,
458
+ );
459
+ allMatches.push(...idMatches);
460
+ }
461
+
462
+ return { noteId: note.id, files: deduplicateMatches(allMatches) };
463
+ }
464
+
465
+ /**
466
+ * Resolve multiple notes in batch, sharing the file cache.
467
+ */
468
+ export async function resolveNotesToFiles(
469
+ notes: NoteForResolution[],
470
+ opts: ResolveOptions,
471
+ ): Promise<ResolvedNote[]> {
472
+ const results: ResolvedNote[] = [];
473
+ for (const note of notes) {
474
+ results.push(await resolveNoteToFiles(note, opts));
475
+ }
476
+ return results;
477
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * CSS selector parser and Tailwind utility class filter.
3
+ * Extracts searchable tokens from CSS selectors captured by the UI Notes React package.
4
+ */
5
+
6
+ export interface SelectorTokens {
7
+ classNames: string[];
8
+ ids: string[];
9
+ tags: string[];
10
+ dataAttributes: Array<{ name: string; value: string | null }>;
11
+ }
12
+
13
+ /**
14
+ * Regex matching common Tailwind CSS utility class prefixes.
15
+ * Classes matching this pattern carry no semantic meaning for file resolution.
16
+ */
17
+ const TAILWIND_UTILITY_RE =
18
+ /^(flex|grid|block|inline|hidden|visible|static|fixed|absolute|relative|sticky|p[xytblr]?-|m[xytblr]?-|text-|bg-|border-|rounded|w-|h-|min-w-|max-w-|gap-|space-|font-|leading-|tracking-|shadow-|opacity-|z-|overflow-|cursor-|transition-|duration-|ease-|animate-|ring-|outline-|placeholder-|divide-|sr-only|not-sr-only|items-|justify-|self-|place-|order-|col-|row-|float-|clear-|object-|aspect-|container|prose|truncate|break-|whitespace-|decoration-|underline|overline|line-through|no-underline|uppercase|lowercase|capitalize|normal-case|italic|not-italic|tabular-nums|oldstyle-nums|lining-nums|proportional-nums|ordinal|slashed-zero|diagonal-fractions|stacked-fractions|list-|align-|table-|border-collapse|border-separate|origin-|scale-|rotate-|translate-|skew-|transform|accent-|appearance-|caret-|scroll-|snap-|touch-|select-|will-change-|fill-|stroke-)/;
19
+
20
+ /**
21
+ * Check if a class name looks like a Tailwind utility class.
22
+ */
23
+ export function isTailwindUtility(className: string): boolean {
24
+ return TAILWIND_UTILITY_RE.test(className);
25
+ }
26
+
27
+ /**
28
+ * Filter out Tailwind utility classes, returning only semantic class names.
29
+ */
30
+ export function filterSemanticClasses(classNames: string[]): string[] {
31
+ return classNames.filter((c) => !isTailwindUtility(c));
32
+ }
33
+
34
+ /**
35
+ * Parse a CSS selector string into searchable tokens.
36
+ *
37
+ * Handles selectors like:
38
+ * [data-testid="sidebar"]
39
+ * #checkout-form
40
+ * nav.sidebar-nav > ul.nav-list
41
+ * div.flex > button.submit-btn
42
+ */
43
+ export function parseSelector(selector: string): SelectorTokens {
44
+ const classNames: string[] = [];
45
+ const ids: string[] = [];
46
+ const tags: string[] = [];
47
+ const dataAttributes: Array<{ name: string; value: string | null }> = [];
48
+
49
+ // Extract data-* attribute selectors: [data-testid="value"] or [data-component="value"]
50
+ const dataMatches = selector.matchAll(
51
+ /\[(data-[\w-]+)(?:="([^"]*)")?\]/g,
52
+ );
53
+ for (const m of dataMatches) {
54
+ dataAttributes.push({ name: m[1], value: m[2] ?? null });
55
+ }
56
+
57
+ // Extract .class-names (handling CSS-escaped chars like \.)
58
+ const classMatches = selector.matchAll(/\.([a-zA-Z_][\w-]*)/g);
59
+ for (const m of classMatches) {
60
+ classNames.push(m[1]);
61
+ }
62
+
63
+ // Extract #ids
64
+ const idMatches = selector.matchAll(/#([a-zA-Z_][\w-]*)/g);
65
+ for (const m of idMatches) {
66
+ ids.push(m[1]);
67
+ }
68
+
69
+ // Extract tag names: bare words at start of selector or after combinators (>, +, ~, space)
70
+ const tagMatches = selector.matchAll(/(?:^|[\s>+~])([a-z][a-z0-9]*)\b/gi);
71
+ for (const m of tagMatches) {
72
+ const tag = m[1].toLowerCase();
73
+ // Exclude common non-tag tokens
74
+ if (tag !== 'data' && tag !== 'not') {
75
+ tags.push(tag);
76
+ }
77
+ }
78
+
79
+ return { classNames, ids, tags, dataAttributes };
80
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Comment",
4
+ "description": "A comment on a note",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique comment identifier" },
8
+ "note_id": { "type": "string", "description": "ID of the parent note" },
9
+ "author": { "type": "string", "description": "Comment author" },
10
+ "body": { "type": "string", "description": "Comment text" },
11
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
12
+ },
13
+ "required": ["id", "note_id", "author", "body", "created_at"]
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Note",
4
+ "description": "A UI feedback note attached to a page element",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique note identifier" },
8
+ "project": { "type": "string", "description": "Project slug this note belongs to" },
9
+ "type": { "type": "string", "enum": ["bug", "ux", "feature", "question"], "description": "Type of feedback" },
10
+ "status": { "type": "string", "enum": ["open", "resolved", "wontfix"], "description": "Current status" },
11
+ "body": { "type": "string", "description": "Note content/description" },
12
+ "url": { "type": "string", "description": "Page URL where the note was created" },
13
+ "component": { "type": ["string", "null"], "description": "Component name from data attributes" },
14
+ "text_content": { "type": ["string", "null"], "description": "Text content of the annotated element" },
15
+ "selector": { "type": ["string", "null"], "description": "CSS selector for the annotated element" },
16
+ "tag": { "type": ["string", "null"], "description": "HTML tag of the annotated element" },
17
+ "author": { "type": ["string", "null"], "description": "Author email or name" },
18
+ "screenshot_url": { "type": ["string", "null"], "description": "URL to the screenshot capture" },
19
+ "resolution": { "type": ["string", "null"], "description": "Resolution message when resolved" },
20
+ "resolved_at": { "type": ["string", "null"], "format": "date-time", "description": "When the note was resolved" },
21
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
22
+ },
23
+ "required": ["id", "project", "type", "status", "body", "url", "created_at"]
24
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Project",
4
+ "description": "A UI Notes project that groups notes",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique project identifier" },
8
+ "slug": { "type": "string", "description": "URL-friendly project identifier" },
9
+ "name": { "type": "string", "description": "Display name" },
10
+ "url_patterns": { "type": "array", "items": { "type": "string" }, "description": "URL glob patterns for auto-matching" },
11
+ "archived": { "type": "boolean", "description": "Whether the project is archived" },
12
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
13
+ },
14
+ "required": ["id", "slug", "name", "created_at"]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Webhook",
4
+ "description": "A webhook endpoint for receiving note events",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique webhook identifier" },
8
+ "project_id": { "type": "string", "description": "Project this webhook belongs to" },
9
+ "url": { "type": "string", "description": "Webhook delivery URL" },
10
+ "secret": { "type": ["string", "null"], "description": "HMAC signing secret" },
11
+ "events": { "type": "array", "items": { "type": "string" }, "description": "Events to subscribe to" },
12
+ "active": { "type": "boolean", "description": "Whether the webhook is active" },
13
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
14
+ },
15
+ "required": ["id", "project_id", "url", "events", "active", "created_at"]
16
+ }