@upgraide/ui-notes-cli 0.1.1 → 0.1.2
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.
- package/commands/bulk-resolve.ts +138 -0
- package/commands/comment.ts +27 -0
- package/commands/context.ts +64 -0
- package/commands/pull.ts +28 -1
- package/commands/visual-diff.ts +366 -0
- package/config.ts +7 -0
- package/index.ts +100 -1
- package/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
|
|
4
|
+
interface BulkResolveOptions {
|
|
5
|
+
project?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
force?: boolean;
|
|
9
|
+
status?: 'resolved' | 'wontfix';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function resolveByComponent(
|
|
13
|
+
config: Config,
|
|
14
|
+
componentName: string,
|
|
15
|
+
opts: BulkResolveOptions,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
await bulkResolve(config, 'component', componentName, opts);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function resolveBySelector(
|
|
21
|
+
config: Config,
|
|
22
|
+
selector: string,
|
|
23
|
+
opts: BulkResolveOptions,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
await bulkResolve(config, 'selector', selector, opts);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function bulkResolve(
|
|
29
|
+
config: Config,
|
|
30
|
+
filterBy: 'component' | 'selector',
|
|
31
|
+
filterValue: string,
|
|
32
|
+
opts: BulkResolveOptions,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const project = opts.project || config.defaultProject;
|
|
35
|
+
if (!project) {
|
|
36
|
+
console.error('No project specified. Use --project or set defaultProject in config.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const status = opts.status || 'resolved';
|
|
41
|
+
const resolution = opts.message || `Bulk ${status} (${filterBy}: ${filterValue})`;
|
|
42
|
+
|
|
43
|
+
if (opts.dryRun) {
|
|
44
|
+
// Fetch matching notes to show what would be resolved
|
|
45
|
+
const response = await apiFetch(config, `/notes?status=open`, { project }) as any;
|
|
46
|
+
const notes = response.data?.notes || [];
|
|
47
|
+
|
|
48
|
+
const matching = notes.filter((n: any) => {
|
|
49
|
+
if (filterBy === 'component') {
|
|
50
|
+
return n.component?.toLowerCase() === filterValue.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
if (filterBy === 'selector') {
|
|
53
|
+
return n.selector?.includes(filterValue);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (matching.length === 0) {
|
|
59
|
+
console.log('No matching open notes found.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`[DRY RUN] Would resolve ${matching.length} notes:`);
|
|
64
|
+
for (const note of matching) {
|
|
65
|
+
console.log(` ${note.id} | ${note.type} | ${truncate(note.body, 60)}`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If not forced, do a dry-run first to show count and confirm
|
|
71
|
+
if (!opts.force) {
|
|
72
|
+
const response = await apiFetch(config, `/notes?status=open`, { project }) as any;
|
|
73
|
+
const notes = response.data?.notes || [];
|
|
74
|
+
|
|
75
|
+
const matching = notes.filter((n: any) => {
|
|
76
|
+
if (filterBy === 'component') {
|
|
77
|
+
return n.component?.toLowerCase() === filterValue.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
if (filterBy === 'selector') {
|
|
80
|
+
return n.selector?.includes(filterValue);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (matching.length === 0) {
|
|
86
|
+
console.log('No matching open notes found.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const label = filterBy === 'component' ? `Component=${filterValue}` : `Selector=${filterValue}`;
|
|
91
|
+
const answer = await confirm(`About to resolve ${matching.length} notes matching ${label}. Continue? y/n `);
|
|
92
|
+
if (!answer) {
|
|
93
|
+
console.log('Aborted.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Call bulk endpoint
|
|
99
|
+
const response = await apiFetch(config, '/notes/bulk', {
|
|
100
|
+
method: 'PATCH',
|
|
101
|
+
body: {
|
|
102
|
+
filter: {
|
|
103
|
+
status: 'open',
|
|
104
|
+
...(filterBy === 'component' ? { component: filterValue } : {}),
|
|
105
|
+
...(filterBy === 'selector' ? { selector: filterValue } : {}),
|
|
106
|
+
},
|
|
107
|
+
update: {
|
|
108
|
+
status,
|
|
109
|
+
resolution,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
project,
|
|
113
|
+
}) as any;
|
|
114
|
+
|
|
115
|
+
const count = response.data?.updated || 0;
|
|
116
|
+
|
|
117
|
+
if (count === 0) {
|
|
118
|
+
console.log('No matching open notes found.');
|
|
119
|
+
} else {
|
|
120
|
+
console.log(`Resolved ${count} notes.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function confirm(question: string): Promise<boolean> {
|
|
125
|
+
const readline = await import('readline');
|
|
126
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
127
|
+
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
rl.question(question, (answer) => {
|
|
130
|
+
rl.close();
|
|
131
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function truncate(s: string, max: number): string {
|
|
137
|
+
return s.length > max ? s.slice(0, max) + '...' : s;
|
|
138
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
|
|
4
|
+
export async function comment(
|
|
5
|
+
config: Config,
|
|
6
|
+
noteId: string,
|
|
7
|
+
body: string,
|
|
8
|
+
project?: string,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
const data = await apiFetch(config, `/notes/${noteId}/comments`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
body: { body },
|
|
13
|
+
project,
|
|
14
|
+
});
|
|
15
|
+
console.log(JSON.stringify(data, null, 2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function listComments(
|
|
19
|
+
config: Config,
|
|
20
|
+
noteId: string,
|
|
21
|
+
project?: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const data = await apiFetch(config, `/notes/${noteId}/comments`, {
|
|
24
|
+
project,
|
|
25
|
+
});
|
|
26
|
+
console.log(JSON.stringify(data, null, 2));
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
import { formatNotesForAI } from '../formatters/ai-context.js';
|
|
4
|
+
import type { ResolvedNote } from '../resolvers/file-resolver.js';
|
|
5
|
+
|
|
6
|
+
interface ContextOptions {
|
|
7
|
+
project?: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
component?: string;
|
|
11
|
+
format?: 'markdown' | 'xml' | 'json';
|
|
12
|
+
limit?: string;
|
|
13
|
+
resolveFiles?: boolean;
|
|
14
|
+
rootDir?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function context(config: Config, opts: ContextOptions): Promise<void> {
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
params.set('status', opts.status || 'open');
|
|
20
|
+
if (opts.type) params.set('type', opts.type);
|
|
21
|
+
if (opts.limit) params.set('limit', opts.limit);
|
|
22
|
+
|
|
23
|
+
const qs = params.toString();
|
|
24
|
+
const path = `/notes${qs ? `?${qs}` : ''}`;
|
|
25
|
+
|
|
26
|
+
const response = await apiFetch(config, path, { project: opts.project }) as any;
|
|
27
|
+
const notes = response.data?.notes || [];
|
|
28
|
+
|
|
29
|
+
// Optionally resolve notes to source files
|
|
30
|
+
let resolvedMap: Map<string, ResolvedNote> | undefined;
|
|
31
|
+
if (opts.resolveFiles) {
|
|
32
|
+
const { resolveNoteToFiles } = await import('../resolvers/file-resolver.js');
|
|
33
|
+
const resolveConfig = config.resolve || {};
|
|
34
|
+
const rootDir = opts.rootDir || resolveConfig.rootDir || process.cwd();
|
|
35
|
+
resolvedMap = new Map();
|
|
36
|
+
|
|
37
|
+
for (const note of notes) {
|
|
38
|
+
const resolved = await resolveNoteToFiles(
|
|
39
|
+
{
|
|
40
|
+
id: note.id,
|
|
41
|
+
component: note.component || null,
|
|
42
|
+
selector: note.selector || null,
|
|
43
|
+
tag: note.tag || null,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
rootDir,
|
|
47
|
+
extensions: resolveConfig.extensions,
|
|
48
|
+
excludeDirs: resolveConfig.excludeDirs,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
resolvedMap.set(note.id, resolved);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const output = formatNotesForAI(notes, {
|
|
56
|
+
format: opts.format || 'markdown',
|
|
57
|
+
componentFilter: opts.component,
|
|
58
|
+
projectName: opts.project || config.defaultProject || 'unknown',
|
|
59
|
+
resolvedNotes: resolvedMap,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Write to stdout -- pure pipeable output, no JSON wrapper, no logging
|
|
63
|
+
process.stdout.write(output);
|
|
64
|
+
}
|
package/commands/pull.ts
CHANGED
|
@@ -6,6 +6,8 @@ interface PullOptions {
|
|
|
6
6
|
status?: string;
|
|
7
7
|
type?: string;
|
|
8
8
|
limit?: string;
|
|
9
|
+
resolveFiles?: boolean;
|
|
10
|
+
rootDir?: string;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export async function pull(config: Config, opts: PullOptions): Promise<void> {
|
|
@@ -17,6 +19,31 @@ export async function pull(config: Config, opts: PullOptions): Promise<void> {
|
|
|
17
19
|
const qs = params.toString();
|
|
18
20
|
const path = `/notes${qs ? `?${qs}` : ''}`;
|
|
19
21
|
|
|
20
|
-
const data = await apiFetch(config, path, { project: opts.project });
|
|
22
|
+
const data = (await apiFetch(config, path, { project: opts.project })) as any;
|
|
23
|
+
|
|
24
|
+
if (opts.resolveFiles) {
|
|
25
|
+
const { resolveNoteToFiles } = await import('../resolvers/file-resolver.js');
|
|
26
|
+
const resolveConfig = config.resolve || {};
|
|
27
|
+
const rootDir = opts.rootDir || resolveConfig.rootDir || process.cwd();
|
|
28
|
+
const notes = data.data?.notes || data.notes || [];
|
|
29
|
+
|
|
30
|
+
for (const note of notes) {
|
|
31
|
+
const resolved = await resolveNoteToFiles(
|
|
32
|
+
{
|
|
33
|
+
id: note.id,
|
|
34
|
+
component: note.component || null,
|
|
35
|
+
selector: note.selector || null,
|
|
36
|
+
tag: note.tag || null,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
rootDir,
|
|
40
|
+
extensions: resolveConfig.extensions,
|
|
41
|
+
excludeDirs: resolveConfig.excludeDirs,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
note.resolved_files = resolved.files;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
console.log(JSON.stringify(data));
|
|
22
49
|
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
|
|
7
|
+
interface VisualDiffOptions {
|
|
8
|
+
project?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
compare?: boolean;
|
|
11
|
+
width?: number;
|
|
12
|
+
fullPage?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SNAPSHOT_DIR = '.uinotes-snapshots';
|
|
16
|
+
|
|
17
|
+
function hashUrl(url: string): string {
|
|
18
|
+
return createHash('md5').update(url).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureDir(dir: string): void {
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadPlaywright() {
|
|
28
|
+
try {
|
|
29
|
+
const pw = await import('playwright');
|
|
30
|
+
return pw;
|
|
31
|
+
} catch {
|
|
32
|
+
console.error('Playwright is not installed.');
|
|
33
|
+
console.error('Install it with: bun add playwright');
|
|
34
|
+
console.error('Then install the browser: bunx playwright install chromium');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function visualDiff(config: Config, opts: VisualDiffOptions): Promise<void> {
|
|
40
|
+
const project = opts.project || config.defaultProject;
|
|
41
|
+
if (!project) {
|
|
42
|
+
console.error('Error: --project is required (or set defaultProject in config)');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const width = opts.width || 1280;
|
|
47
|
+
const fullPage = opts.fullPage !== false;
|
|
48
|
+
|
|
49
|
+
// Step 1: Fetch open notes to collect URLs
|
|
50
|
+
console.log(`Fetching open notes for project "${project}"...`);
|
|
51
|
+
const data = (await apiFetch(config, '/notes?status=open&limit=500', { project })) as any;
|
|
52
|
+
const notes = data.data?.notes || [];
|
|
53
|
+
|
|
54
|
+
if (notes.length === 0) {
|
|
55
|
+
console.log('No open notes found. Nothing to capture.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 2: Extract unique URLs
|
|
60
|
+
const urlSet = new Set<string>();
|
|
61
|
+
for (const note of notes) {
|
|
62
|
+
if (note.url) urlSet.add(note.url);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let urls = Array.from(urlSet);
|
|
66
|
+
|
|
67
|
+
// Filter to specific URL if provided
|
|
68
|
+
if (opts.url) {
|
|
69
|
+
urls = urls.filter((u) => u.includes(opts.url!));
|
|
70
|
+
if (urls.length === 0) {
|
|
71
|
+
console.log(`No notes found matching URL "${opts.url}".`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(`Found ${urls.length} unique URL(s) across ${notes.length} open notes\n`);
|
|
77
|
+
|
|
78
|
+
if (opts.compare) {
|
|
79
|
+
await runComparison(config, project, notes, urls, width, fullPage);
|
|
80
|
+
} else {
|
|
81
|
+
await captureSnapshots(urls, width, fullPage);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function captureSnapshots(urls: string[], width: number, fullPage: boolean): Promise<void> {
|
|
86
|
+
const pw = await loadPlaywright();
|
|
87
|
+
|
|
88
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
89
|
+
const snapshotDir = join(SNAPSHOT_DIR, timestamp);
|
|
90
|
+
ensureDir(snapshotDir);
|
|
91
|
+
|
|
92
|
+
let browser;
|
|
93
|
+
try {
|
|
94
|
+
browser = await pw.chromium.launch({ headless: true });
|
|
95
|
+
} catch {
|
|
96
|
+
console.error('Chromium browser not found.');
|
|
97
|
+
console.error('Run: bunx playwright install chromium');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for (const url of urls) {
|
|
103
|
+
console.log(`Capturing: ${url}`);
|
|
104
|
+
try {
|
|
105
|
+
const context = await browser.newContext({ viewport: { width, height: 800 } });
|
|
106
|
+
const page = await context.newPage();
|
|
107
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
108
|
+
await page.waitForTimeout(1000);
|
|
109
|
+
|
|
110
|
+
const filename = `${hashUrl(url)}.png`;
|
|
111
|
+
const filepath = join(snapshotDir, filename);
|
|
112
|
+
await page.screenshot({ fullPage, path: filepath, type: 'png' });
|
|
113
|
+
await context.close();
|
|
114
|
+
|
|
115
|
+
console.log(` Saved: ${filepath}`);
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
console.error(` Failed: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
await browser.close();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Save a manifest mapping URL hashes to URLs
|
|
125
|
+
const manifest: Record<string, string> = {};
|
|
126
|
+
for (const url of urls) {
|
|
127
|
+
manifest[hashUrl(url)] = url;
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(join(snapshotDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
130
|
+
|
|
131
|
+
console.log(`\nSnapshots saved to ${snapshotDir}`);
|
|
132
|
+
console.log(`Run with --compare to diff against this snapshot set.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runComparison(
|
|
136
|
+
config: Config,
|
|
137
|
+
project: string,
|
|
138
|
+
notes: any[],
|
|
139
|
+
urls: string[],
|
|
140
|
+
width: number,
|
|
141
|
+
fullPage: boolean,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
// Find the most recent previous snapshot set
|
|
144
|
+
if (!existsSync(SNAPSHOT_DIR)) {
|
|
145
|
+
console.error(`No previous snapshots found in ${SNAPSHOT_DIR}. Run without --compare first.`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snapshotDirs = readdirSync(SNAPSHOT_DIR)
|
|
150
|
+
.filter((d) => !d.startsWith('.'))
|
|
151
|
+
.sort()
|
|
152
|
+
.reverse();
|
|
153
|
+
|
|
154
|
+
if (snapshotDirs.length === 0) {
|
|
155
|
+
console.error(`No previous snapshots found in ${SNAPSHOT_DIR}. Run without --compare first.`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const previousDir = join(SNAPSHOT_DIR, snapshotDirs[0]);
|
|
160
|
+
console.log(`Comparing against previous snapshot: ${snapshotDirs[0]}\n`);
|
|
161
|
+
|
|
162
|
+
// Load previous manifest
|
|
163
|
+
const manifestPath = join(previousDir, 'manifest.json');
|
|
164
|
+
if (!existsSync(manifestPath)) {
|
|
165
|
+
console.error(`No manifest.json found in ${previousDir}. Cannot compare.`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const previousManifest: Record<string, string> = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
169
|
+
|
|
170
|
+
// Dynamically import pixelmatch and pngjs
|
|
171
|
+
let pixelmatch: any;
|
|
172
|
+
let PNG: any;
|
|
173
|
+
try {
|
|
174
|
+
pixelmatch = (await import('pixelmatch')).default;
|
|
175
|
+
PNG = (await import('pngjs')).PNG;
|
|
176
|
+
} catch {
|
|
177
|
+
console.error('pixelmatch or pngjs not installed.');
|
|
178
|
+
console.error('Install with: bun add pixelmatch pngjs');
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Capture current state
|
|
183
|
+
const pw = await loadPlaywright();
|
|
184
|
+
let browser;
|
|
185
|
+
try {
|
|
186
|
+
browser = await pw.chromium.launch({ headless: true });
|
|
187
|
+
} catch {
|
|
188
|
+
console.error('Chromium browser not found.');
|
|
189
|
+
console.error('Run: bunx playwright install chromium');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
194
|
+
const currentDir = join(SNAPSHOT_DIR, timestamp);
|
|
195
|
+
const diffDir = join(SNAPSHOT_DIR, `${timestamp}-diffs`);
|
|
196
|
+
ensureDir(currentDir);
|
|
197
|
+
ensureDir(diffDir);
|
|
198
|
+
|
|
199
|
+
interface DiffResult {
|
|
200
|
+
url: string;
|
|
201
|
+
diffPercentage: number;
|
|
202
|
+
affectedNotes: number;
|
|
203
|
+
diffPath: string | null;
|
|
204
|
+
beforePath: string;
|
|
205
|
+
afterPath: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const results: DiffResult[] = [];
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
for (const url of urls) {
|
|
212
|
+
const urlHash = hashUrl(url);
|
|
213
|
+
const previousScreenshot = join(previousDir, `${urlHash}.png`);
|
|
214
|
+
|
|
215
|
+
if (!existsSync(previousScreenshot)) {
|
|
216
|
+
console.log(`Skipping ${url} - no previous screenshot found`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(`Processing: ${url}`);
|
|
221
|
+
|
|
222
|
+
// Take current screenshot
|
|
223
|
+
let currentScreenshotPath: string;
|
|
224
|
+
try {
|
|
225
|
+
const context = await browser.newContext({ viewport: { width, height: 800 } });
|
|
226
|
+
const page = await context.newPage();
|
|
227
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
228
|
+
await page.waitForTimeout(1000);
|
|
229
|
+
|
|
230
|
+
currentScreenshotPath = join(currentDir, `${urlHash}.png`);
|
|
231
|
+
await page.screenshot({ fullPage, path: currentScreenshotPath, type: 'png' });
|
|
232
|
+
await context.close();
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
console.error(` Failed to capture: ${err.message}`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Run pixelmatch comparison
|
|
239
|
+
try {
|
|
240
|
+
const beforeBuf = readFileSync(previousScreenshot);
|
|
241
|
+
const afterBuf = readFileSync(currentScreenshotPath);
|
|
242
|
+
|
|
243
|
+
const beforePng = PNG.sync.read(beforeBuf);
|
|
244
|
+
const afterPng = PNG.sync.read(afterBuf);
|
|
245
|
+
|
|
246
|
+
// Ensure same dimensions - use the smaller of the two
|
|
247
|
+
const minWidth = Math.min(beforePng.width, afterPng.width);
|
|
248
|
+
const minHeight = Math.min(beforePng.height, afterPng.height);
|
|
249
|
+
|
|
250
|
+
// If dimensions differ, we need to crop to common area
|
|
251
|
+
let beforeData = beforePng.data;
|
|
252
|
+
let afterData = afterPng.data;
|
|
253
|
+
|
|
254
|
+
if (beforePng.width !== afterPng.width || beforePng.height !== afterPng.height) {
|
|
255
|
+
console.log(` Note: Image sizes differ (before: ${beforePng.width}x${beforePng.height}, after: ${afterPng.width}x${afterPng.height})`);
|
|
256
|
+
console.log(` Comparing common area: ${minWidth}x${minHeight}`);
|
|
257
|
+
|
|
258
|
+
// Crop both to the minimum dimensions
|
|
259
|
+
beforeData = cropImageData(beforePng.data, beforePng.width, minWidth, minHeight);
|
|
260
|
+
afterData = cropImageData(afterPng.data, afterPng.width, minWidth, minHeight);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const diffPng = new PNG({ width: minWidth, height: minHeight });
|
|
264
|
+
|
|
265
|
+
const numDiffPixels = pixelmatch(
|
|
266
|
+
beforeData,
|
|
267
|
+
afterData,
|
|
268
|
+
diffPng.data,
|
|
269
|
+
minWidth,
|
|
270
|
+
minHeight,
|
|
271
|
+
{ threshold: 0.1 },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const totalPixels = minWidth * minHeight;
|
|
275
|
+
const diffPercentage = (numDiffPixels / totalPixels) * 100;
|
|
276
|
+
|
|
277
|
+
// Save diff image
|
|
278
|
+
const diffPath = join(diffDir, `${urlHash}-diff.png`);
|
|
279
|
+
writeFileSync(diffPath, PNG.sync.write(diffPng));
|
|
280
|
+
|
|
281
|
+
const affectedNotes = notes.filter((n: any) => n.url === url).length;
|
|
282
|
+
|
|
283
|
+
console.log(` Diff: ${diffPercentage.toFixed(2)}% (${numDiffPixels} pixels changed)`);
|
|
284
|
+
console.log(` Affected notes: ${affectedNotes}`);
|
|
285
|
+
|
|
286
|
+
results.push({
|
|
287
|
+
url,
|
|
288
|
+
diffPercentage,
|
|
289
|
+
affectedNotes,
|
|
290
|
+
diffPath,
|
|
291
|
+
beforePath: previousScreenshot,
|
|
292
|
+
afterPath: currentScreenshotPath,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Upload diff records to API for each affected note
|
|
296
|
+
for (const note of notes.filter((n: any) => n.url === url)) {
|
|
297
|
+
try {
|
|
298
|
+
await apiFetch(config, `/notes/${note.id}/diffs`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
project,
|
|
301
|
+
body: {
|
|
302
|
+
before_url: previousScreenshot,
|
|
303
|
+
after_url: currentScreenshotPath,
|
|
304
|
+
diff_url: diffPath,
|
|
305
|
+
diff_percentage: diffPercentage,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
} catch (err: any) {
|
|
309
|
+
// Non-fatal: diff record upload failed
|
|
310
|
+
console.error(` Warning: Could not save diff record for note ${note.id}: ${err.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (err: any) {
|
|
314
|
+
console.error(` Diff failed: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
await browser.close();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Save manifest for current snapshot
|
|
322
|
+
const manifest: Record<string, string> = {};
|
|
323
|
+
for (const url of urls) {
|
|
324
|
+
manifest[hashUrl(url)] = url;
|
|
325
|
+
}
|
|
326
|
+
writeFileSync(join(currentDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
327
|
+
|
|
328
|
+
// Print summary
|
|
329
|
+
console.log('\n--- Visual Diff Summary ---');
|
|
330
|
+
console.log(`URLs compared: ${results.length}`);
|
|
331
|
+
|
|
332
|
+
const changed = results.filter((r) => r.diffPercentage > 0);
|
|
333
|
+
if (changed.length === 0) {
|
|
334
|
+
console.log('No visual changes detected.');
|
|
335
|
+
} else {
|
|
336
|
+
console.log(`\nVisual changes on ${changed.length} URL(s):\n`);
|
|
337
|
+
console.log(padRight('URL', 50) + padRight('Notes', 8) + padRight('Change %', 12) + 'Status');
|
|
338
|
+
console.log('-'.repeat(80));
|
|
339
|
+
for (const r of results) {
|
|
340
|
+
const status = r.diffPercentage === 0 ? 'No change' : r.diffPercentage < 1 ? 'Minor' : r.diffPercentage < 5 ? 'Moderate' : 'Significant';
|
|
341
|
+
const urlDisplay = r.url.length > 48 ? r.url.slice(0, 45) + '...' : r.url;
|
|
342
|
+
console.log(
|
|
343
|
+
padRight(urlDisplay, 50) +
|
|
344
|
+
padRight(String(r.affectedNotes), 8) +
|
|
345
|
+
padRight(r.diffPercentage.toFixed(2) + '%', 12) +
|
|
346
|
+
status,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(`\nDiff images saved to: ${diffDir}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function cropImageData(data: Uint8Array, srcWidth: number, targetWidth: number, targetHeight: number): Uint8Array {
|
|
355
|
+
const cropped = new Uint8Array(targetWidth * targetHeight * 4);
|
|
356
|
+
for (let y = 0; y < targetHeight; y++) {
|
|
357
|
+
const srcOffset = y * srcWidth * 4;
|
|
358
|
+
const dstOffset = y * targetWidth * 4;
|
|
359
|
+
cropped.set(data.subarray(srcOffset, srcOffset + targetWidth * 4), dstOffset);
|
|
360
|
+
}
|
|
361
|
+
return cropped;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function padRight(str: string, len: number): string {
|
|
365
|
+
return str.length >= len ? str : str + ' '.repeat(len - str.length);
|
|
366
|
+
}
|
package/config.ts
CHANGED
|
@@ -2,10 +2,17 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
|
|
5
|
+
export interface ResolveConfig {
|
|
6
|
+
rootDir?: string;
|
|
7
|
+
extensions?: string[];
|
|
8
|
+
excludeDirs?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
export interface Config {
|
|
6
12
|
apiUrl: string;
|
|
7
13
|
apiKey: string;
|
|
8
14
|
defaultProject?: string;
|
|
15
|
+
resolve?: ResolveConfig;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
const GLOBAL_PATH = join(homedir(), '.uinotes.json');
|
package/index.ts
CHANGED
|
@@ -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');
|