repoimage 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.
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +28 -0
- package/PROJECT-AGENTS.md +55 -0
- package/README.md +153 -0
- package/TODO.md +132 -0
- package/client/index.html +12 -0
- package/client/package.json +23 -0
- package/client/src/App.tsx +599 -0
- package/client/src/components/FsBrowser.tsx +210 -0
- package/client/src/components/Settings.tsx +81 -0
- package/client/src/index.css +797 -0
- package/client/src/lib/api.ts +69 -0
- package/client/src/lib/collect.ts +204 -0
- package/client/src/lib/format.ts +96 -0
- package/client/src/lib/session.ts +58 -0
- package/client/src/main.tsx +10 -0
- package/client/src/vite-env.d.ts +1 -0
- package/client/tsconfig.json +18 -0
- package/client/vite.config.ts +27 -0
- package/docs/README.md +28 -0
- package/docs/api/overview.md +65 -0
- package/docs/api/scan.md +188 -0
- package/docs/architecture.md +155 -0
- package/docs/design/invariants.md +19 -0
- package/docs/design/role-system.md +50 -0
- package/docs/development/README.md +94 -0
- package/docs/features/README.md +21 -0
- package/docs/features/compression-score.md +75 -0
- package/docs/features/exclusions.md +63 -0
- package/docs/features/session.md +64 -0
- package/package.json +37 -0
- package/server/dist/cli.d.ts +3 -0
- package/server/dist/cli.d.ts.map +1 -0
- package/server/dist/cli.js +54 -0
- package/server/dist/cli.js.map +1 -0
- package/server/dist/fs-list.d.ts +3 -0
- package/server/dist/fs-list.d.ts.map +1 -0
- package/server/dist/fs-list.js +73 -0
- package/server/dist/fs-list.js.map +1 -0
- package/server/dist/paths.d.ts +3 -0
- package/server/dist/paths.d.ts.map +1 -0
- package/server/dist/paths.js +12 -0
- package/server/dist/paths.js.map +1 -0
- package/server/dist/scan.d.ts +16 -0
- package/server/dist/scan.d.ts.map +1 -0
- package/server/dist/scan.js +158 -0
- package/server/dist/scan.js.map +1 -0
- package/server/dist/server.d.ts +6 -0
- package/server/dist/server.d.ts.map +1 -0
- package/server/dist/server.js +313 -0
- package/server/dist/server.js.map +1 -0
- package/server/package.json +22 -0
- package/server/src/cli.ts +63 -0
- package/server/src/fs-list.ts +70 -0
- package/server/src/paths.ts +6 -0
- package/server/src/scan.ts +203 -0
- package/server/src/server.ts +356 -0
- package/server/tsconfig.json +9 -0
- package/shared/package.json +10 -0
- package/shared/src/constants.ts +37 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/role-guess.ts +103 -0
- package/shared/src/schema.ts +18 -0
- package/shared/src/types.ts +36 -0
- package/shared/tsconfig.json +9 -0
- package/test/cli.test.js +56 -0
- package/test/fs-list.test.js +39 -0
- package/test/role-guess.test.js +50 -0
- package/test/scan.test.js +150 -0
- package/test/server.test.js +308 -0
- package/tsconfig.base.json +14 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ImageRow, ScanIssue } from '@repoimage/shared';
|
|
2
|
+
|
|
3
|
+
const FILE_PROTOCOL_ERR =
|
|
4
|
+
'This page was opened as a local file (file://). Folder browse and server scan need the RepoImage GUI. Run npm run dev or npm run gui from the repo, then open the URL it prints.';
|
|
5
|
+
|
|
6
|
+
export function resolveApiOrigin(): string | null {
|
|
7
|
+
const p = location.protocol;
|
|
8
|
+
if (p === 'http:' || p === 'https:') return '';
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const API_ORIGIN = resolveApiOrigin();
|
|
13
|
+
|
|
14
|
+
export function apiUrl(resourcePath: string): string {
|
|
15
|
+
const rel = resourcePath.startsWith('/') ? resourcePath : `/${resourcePath}`;
|
|
16
|
+
if (API_ORIGIN === null) throw new Error(FILE_PROTOCOL_ERR);
|
|
17
|
+
return API_ORIGIN ? `${API_ORIGIN}${rel}` : rel;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const FILE_PROTOCOL_ERROR = FILE_PROTOCOL_ERR;
|
|
21
|
+
|
|
22
|
+
async function parseJsonResponse(res: Response): Promise<Record<string, unknown>> {
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
let data: Record<string, unknown>;
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(text) as Record<string, unknown>;
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
text.trim().slice(0, 200) || `HTTP ${res.status} — is the API server running?`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error((data.error as string) || `HTTP ${res.status}`);
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchApiJson(url: string): Promise<Record<string, unknown>> {
|
|
39
|
+
const res = await fetch(apiUrl(url));
|
|
40
|
+
return parseJsonResponse(res);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function fetchScanFolder(
|
|
44
|
+
folder: string,
|
|
45
|
+
options: { excludeCommonFolders?: boolean } = {}
|
|
46
|
+
): Promise<{
|
|
47
|
+
images: ImageRow[];
|
|
48
|
+
issues: ScanIssue[];
|
|
49
|
+
}> {
|
|
50
|
+
const res = await fetch(apiUrl('/api/scan'), {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ folder, excludeCommonFolders: options.excludeCommonFolders })
|
|
54
|
+
});
|
|
55
|
+
const data = await parseJsonResponse(res);
|
|
56
|
+
return data as { images: ImageRow[]; issues: ScanIssue[] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function fetchScanEntries(
|
|
60
|
+
entries: Array<{ path: string; size: number; width?: number | null; height?: number | null }>
|
|
61
|
+
): Promise<{ images: ImageRow[]; issues: ScanIssue[] }> {
|
|
62
|
+
const res = await fetch(apiUrl('/api/scan-entries'), {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ entries })
|
|
66
|
+
});
|
|
67
|
+
const data = await parseJsonResponse(res);
|
|
68
|
+
return data as { images: ImageRow[]; issues: ScanIssue[] };
|
|
69
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { IMAGE_EXTENSIONS } from '@repoimage/shared';
|
|
2
|
+
|
|
3
|
+
export const DIM_BATCH = 8;
|
|
4
|
+
export const WALK_REPORT_EVERY = 48;
|
|
5
|
+
export const FILELIST_CHUNK = 120;
|
|
6
|
+
|
|
7
|
+
export interface PickedFile {
|
|
8
|
+
path: string;
|
|
9
|
+
file: File;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function yieldToPaint(): Promise<void> {
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
requestAnimationFrame(() => {
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
requestAnimationFrame(() => resolve());
|
|
17
|
+
}, 0);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isImageFileName(name: string): boolean {
|
|
23
|
+
const lower = name.toLowerCase();
|
|
24
|
+
const dot = lower.lastIndexOf('.');
|
|
25
|
+
if (dot === -1) return false;
|
|
26
|
+
return (IMAGE_EXTENSIONS as readonly string[]).includes(lower.slice(dot));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function collectFromDirectoryHandle(
|
|
30
|
+
dirHandle: FileSystemDirectoryHandle,
|
|
31
|
+
basePath: string,
|
|
32
|
+
onProgress?: (visited: number, imageCount: number) => void
|
|
33
|
+
): Promise<PickedFile[]> {
|
|
34
|
+
const out: PickedFile[] = [];
|
|
35
|
+
let visited = 0;
|
|
36
|
+
|
|
37
|
+
if (onProgress) {
|
|
38
|
+
onProgress(0, 0);
|
|
39
|
+
await yieldToPaint();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function walk(dh: FileSystemDirectoryHandle, bp: string): Promise<void> {
|
|
43
|
+
for await (const entry of dh.values()) {
|
|
44
|
+
visited++;
|
|
45
|
+
if (
|
|
46
|
+
onProgress &&
|
|
47
|
+
(visited % WALK_REPORT_EVERY === 0 || (out.length > 0 && out.length % 24 === 0))
|
|
48
|
+
) {
|
|
49
|
+
onProgress(visited, out.length);
|
|
50
|
+
await yieldToPaint();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const name = entry.name;
|
|
54
|
+
const rel = bp ? `${bp}/${name}` : name;
|
|
55
|
+
if (entry.kind === 'directory') {
|
|
56
|
+
await walk(entry, rel);
|
|
57
|
+
} else if (entry.kind === 'file' && isImageFileName(name)) {
|
|
58
|
+
const file = await entry.getFile();
|
|
59
|
+
out.push({ path: rel.split('\\').join('/'), file });
|
|
60
|
+
if (onProgress && out.length % 28 === 0) {
|
|
61
|
+
onProgress(visited, out.length);
|
|
62
|
+
await yieldToPaint();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await walk(dirHandle, basePath);
|
|
69
|
+
if (onProgress) onProgress(visited, out.length);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function collectFromFileInputListAsync(
|
|
74
|
+
fileList: FileList,
|
|
75
|
+
onProgress?: (processed: number, total: number, imageCount: number) => void
|
|
76
|
+
): Promise<PickedFile[]> {
|
|
77
|
+
const out: PickedFile[] = [];
|
|
78
|
+
const n = fileList.length;
|
|
79
|
+
await yieldToPaint();
|
|
80
|
+
for (let i = 0; i < n; i += FILELIST_CHUNK) {
|
|
81
|
+
const end = Math.min(i + FILELIST_CHUNK, n);
|
|
82
|
+
for (let j = i; j < end; j++) {
|
|
83
|
+
const file = fileList[j];
|
|
84
|
+
const rel = (file.webkitRelativePath || file.name).split('\\').join('/');
|
|
85
|
+
const seg = rel.split('/');
|
|
86
|
+
const baseName = seg[seg.length - 1] || file.name;
|
|
87
|
+
if (!isImageFileName(baseName)) continue;
|
|
88
|
+
out.push({ path: rel, file });
|
|
89
|
+
}
|
|
90
|
+
if (onProgress) onProgress(end, n, out.length);
|
|
91
|
+
await yieldToPaint();
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readImageDimensions(
|
|
97
|
+
file: File
|
|
98
|
+
): Promise<{ width: number | null; height: number | null }> {
|
|
99
|
+
try {
|
|
100
|
+
const bmp = await createImageBitmap(file);
|
|
101
|
+
try {
|
|
102
|
+
return { width: bmp.width, height: bmp.height };
|
|
103
|
+
} finally {
|
|
104
|
+
bmp.close();
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return new Promise(resolve => {
|
|
108
|
+
const url = URL.createObjectURL(file);
|
|
109
|
+
const img = new Image();
|
|
110
|
+
img.onload = () => {
|
|
111
|
+
URL.revokeObjectURL(url);
|
|
112
|
+
resolve({
|
|
113
|
+
width: img.naturalWidth || null,
|
|
114
|
+
height: img.naturalHeight || null
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
img.onerror = () => {
|
|
118
|
+
URL.revokeObjectURL(url);
|
|
119
|
+
resolve({ width: null, height: null });
|
|
120
|
+
};
|
|
121
|
+
img.src = url;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function buildEntriesFromPicked(
|
|
127
|
+
picked: PickedFile[],
|
|
128
|
+
onProgress?: (done: number, total: number) => void
|
|
129
|
+
): Promise<
|
|
130
|
+
Array<{ path: string; size: number; width: number | null; height: number | null }>
|
|
131
|
+
> {
|
|
132
|
+
const entries: Array<{
|
|
133
|
+
path: string;
|
|
134
|
+
size: number;
|
|
135
|
+
width: number | null;
|
|
136
|
+
height: number | null;
|
|
137
|
+
}> = [];
|
|
138
|
+
const total = picked.length;
|
|
139
|
+
let done = 0;
|
|
140
|
+
for (let i = 0; i < picked.length; i += DIM_BATCH) {
|
|
141
|
+
const slice = picked.slice(i, i + DIM_BATCH);
|
|
142
|
+
const batch = await Promise.all(
|
|
143
|
+
slice.map(async ({ path: relPath, file }) => {
|
|
144
|
+
const dims = await readImageDimensions(file);
|
|
145
|
+
return {
|
|
146
|
+
path: relPath.split('\\').join('/'),
|
|
147
|
+
size: file.size,
|
|
148
|
+
width: dims.width,
|
|
149
|
+
height: dims.height
|
|
150
|
+
};
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
entries.push(...batch);
|
|
154
|
+
done += slice.length;
|
|
155
|
+
if (onProgress) onProgress(done, total);
|
|
156
|
+
await yieldToPaint();
|
|
157
|
+
}
|
|
158
|
+
return entries;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function pickerRootLabelFromPaths(paths: PickedFile[]): string {
|
|
162
|
+
if (!paths.length) return '';
|
|
163
|
+
const first = paths[0].path.split('/')[0];
|
|
164
|
+
return first || '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function pathNorm(p: string): string {
|
|
168
|
+
return String(p || '').replace(/\\/g, '/');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function pathDirname(norm: string): string {
|
|
172
|
+
const trimmed = norm.replace(/\/+$/, '');
|
|
173
|
+
const i = trimmed.lastIndexOf('/');
|
|
174
|
+
if (i === -1) return '.';
|
|
175
|
+
if (i === 0) return '/';
|
|
176
|
+
return trimmed.slice(0, i);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function commonAncestorDir(absPaths: string[]): string {
|
|
180
|
+
const dirs = absPaths.map(pathNorm).map(pathDirname);
|
|
181
|
+
let r = dirs[0];
|
|
182
|
+
if (!r) return '';
|
|
183
|
+
for (let i = 1; i < dirs.length; i++) {
|
|
184
|
+
let d = dirs[i];
|
|
185
|
+
while (d && r && d !== r && !d.startsWith(r + '/')) {
|
|
186
|
+
const next = pathDirname(r);
|
|
187
|
+
if (next === r) break;
|
|
188
|
+
r = next;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return r || '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function tryDeriveAbsoluteRootFromPicked(list: PickedFile[]): string {
|
|
195
|
+
const abs: string[] = [];
|
|
196
|
+
for (const item of list) {
|
|
197
|
+
const f = item.file as File & { path?: string };
|
|
198
|
+
if (f.path && f.path.length) {
|
|
199
|
+
abs.push(f.path);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!abs.length) return '';
|
|
203
|
+
return commonAncestorDir(abs);
|
|
204
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ImageRow } from '@repoimage/shared';
|
|
2
|
+
|
|
3
|
+
export function formatFileSize(bytes: number | null | undefined): string {
|
|
4
|
+
if (bytes == null) return '—';
|
|
5
|
+
if (bytes === 0) return '0 B';
|
|
6
|
+
const k = 1024;
|
|
7
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
8
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
9
|
+
return `${parseFloat((bytes / k ** i).toFixed(2))} ${units[i]}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scoreClass(score: string | null): string {
|
|
13
|
+
if (score == null) return 'score--na';
|
|
14
|
+
const n = parseFloat(score);
|
|
15
|
+
if (n >= 3.5) return 'score--good';
|
|
16
|
+
if (n >= 2.0) return 'score--mid';
|
|
17
|
+
return 'score--bad';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function barFillClass(score: string | null): string {
|
|
21
|
+
if (score == null) return '';
|
|
22
|
+
const n = parseFloat(score);
|
|
23
|
+
if (n >= 3.5) return 'bar__fill--good';
|
|
24
|
+
if (n >= 2.0) return 'bar__fill--mid';
|
|
25
|
+
return 'bar__fill--bad';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function splitPath(p: string): { dir: string; file: string } {
|
|
29
|
+
const lastSlash = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
|
30
|
+
if (lastSlash === -1) return { dir: '', file: p };
|
|
31
|
+
return { dir: p.substring(0, lastSlash + 1), file: p.substring(lastSlash + 1) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function escapeHtml(str: string): string {
|
|
35
|
+
return str
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SortKey =
|
|
43
|
+
| 'path-asc'
|
|
44
|
+
| 'path-desc'
|
|
45
|
+
| 'size-desc'
|
|
46
|
+
| 'size-asc'
|
|
47
|
+
| 'score-desc'
|
|
48
|
+
| 'score-asc'
|
|
49
|
+
| 'dimensions-desc'
|
|
50
|
+
| 'dimensions-asc';
|
|
51
|
+
|
|
52
|
+
export function applyFilters(
|
|
53
|
+
images: ImageRow[],
|
|
54
|
+
filterText: string,
|
|
55
|
+
minScore: string,
|
|
56
|
+
maxScore: string
|
|
57
|
+
): ImageRow[] {
|
|
58
|
+
const ft = filterText.trim().toLowerCase();
|
|
59
|
+
const min = minScore !== '' ? parseFloat(minScore) : null;
|
|
60
|
+
const max = maxScore !== '' ? parseFloat(maxScore) : null;
|
|
61
|
+
|
|
62
|
+
return images.filter(img => {
|
|
63
|
+
if (ft && !img.path.toLowerCase().includes(ft)) return false;
|
|
64
|
+
const s = img.compressionRatio != null ? parseFloat(img.compressionRatio) : null;
|
|
65
|
+
if (min != null && (s == null || s < min)) return false;
|
|
66
|
+
if (max != null && (s == null || s > max)) return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applySort(images: ImageRow[], key: SortKey): ImageRow[] {
|
|
72
|
+
const sorted = [...images];
|
|
73
|
+
sorted.sort((a, b) => {
|
|
74
|
+
switch (key) {
|
|
75
|
+
case 'path-asc':
|
|
76
|
+
return a.path.localeCompare(b.path);
|
|
77
|
+
case 'path-desc':
|
|
78
|
+
return b.path.localeCompare(a.path);
|
|
79
|
+
case 'size-desc':
|
|
80
|
+
return (b.size || 0) - (a.size || 0);
|
|
81
|
+
case 'size-asc':
|
|
82
|
+
return (a.size || 0) - (b.size || 0);
|
|
83
|
+
case 'score-desc':
|
|
84
|
+
return (parseFloat(b.compressionRatio ?? '') || 0) - (parseFloat(a.compressionRatio ?? '') || 0);
|
|
85
|
+
case 'score-asc':
|
|
86
|
+
return (parseFloat(a.compressionRatio ?? '') || 0) - (parseFloat(b.compressionRatio ?? '') || 0);
|
|
87
|
+
case 'dimensions-desc':
|
|
88
|
+
return (b.width || 0) * (b.height || 0) - (a.width || 0) * (a.height || 0);
|
|
89
|
+
case 'dimensions-asc':
|
|
90
|
+
return (a.width || 0) * (a.height || 0) - (b.width || 0) * (b.height || 0);
|
|
91
|
+
default:
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return sorted;
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ImageRow } from '@repoimage/shared';
|
|
2
|
+
|
|
3
|
+
export interface SessionState {
|
|
4
|
+
folderInput: string;
|
|
5
|
+
excludeCommonFolders: boolean;
|
|
6
|
+
cachedScanResults: ImageRow[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const KEYS = {
|
|
10
|
+
folderInput: 'folderInput',
|
|
11
|
+
excludeCommonFolders: 'excludeCommonFolders',
|
|
12
|
+
cachedScanResults: 'cachedScanResults'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function loadSessionState(): Partial<SessionState> {
|
|
16
|
+
const state: Partial<SessionState> = {};
|
|
17
|
+
|
|
18
|
+
const folder = localStorage.getItem(KEYS.folderInput);
|
|
19
|
+
if (folder) {
|
|
20
|
+
state.folderInput = folder;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const exclude = localStorage.getItem(KEYS.excludeCommonFolders);
|
|
24
|
+
if (exclude === 'true') {
|
|
25
|
+
state.excludeCommonFolders = true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cached = localStorage.getItem(KEYS.cachedScanResults);
|
|
29
|
+
if (cached) {
|
|
30
|
+
try {
|
|
31
|
+
state.cachedScanResults = JSON.parse(cached) as ImageRow[];
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore invalid cache
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveSessionFolder(folder: string): void {
|
|
41
|
+
localStorage.setItem(KEYS.folderInput, folder);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveSessionExclude(exclude: boolean): void {
|
|
45
|
+
localStorage.setItem(KEYS.excludeCommonFolders, String(exclude));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveSessionScanResults(images: ImageRow[]): void {
|
|
49
|
+
if (images.length > 0) {
|
|
50
|
+
localStorage.setItem(KEYS.cachedScanResults, JSON.stringify(images));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearSessionData(): void {
|
|
55
|
+
localStorage.removeItem(KEYS.folderInput);
|
|
56
|
+
localStorage.removeItem(KEYS.excludeCommonFolders);
|
|
57
|
+
localStorage.removeItem(KEYS.cachedScanResults);
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"paths": {
|
|
13
|
+
"@repoimage/shared": ["../shared/src/index.ts"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"references": [{ "path": "../shared" }]
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
|
|
5
|
+
const apiPort = process.env.REPOIMAGE_API_PORT || '3847';
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
'@repoimage/shared': path.resolve(__dirname, '../shared/src/index.ts')
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
server: {
|
|
15
|
+
port: 5173,
|
|
16
|
+
proxy: {
|
|
17
|
+
'/api': {
|
|
18
|
+
target: `http://127.0.0.1:${apiPort}`,
|
|
19
|
+
changeOrigin: true
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
build: {
|
|
24
|
+
outDir: 'dist',
|
|
25
|
+
emptyOutDir: true
|
|
26
|
+
}
|
|
27
|
+
});
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# RepoImage Documentation
|
|
2
|
+
|
|
3
|
+
Welcome to the RepoImage developer documentation. This folder contains guides for understanding the architecture, working with the API, and developing features.
|
|
4
|
+
|
|
5
|
+
## Quick Links
|
|
6
|
+
|
|
7
|
+
- **[Architecture](architecture.md)** — Workspace layout, data flow, package responsibilities
|
|
8
|
+
- **[API Reference](api/)** — HTTP endpoints, parameters, and response formats
|
|
9
|
+
- **[Development](development/)** — Local setup, testing strategies, and feature development
|
|
10
|
+
- **[Features](features/)** — User-facing feature documentation
|
|
11
|
+
- **[Design](design/)** — Architectural decisions and global invariants
|
|
12
|
+
|
|
13
|
+
## Key Concepts
|
|
14
|
+
|
|
15
|
+
**Image Roles:** Every image can have a suggested role (`hero`, `content`, `thumbnail`, `unknown`) based on heuristics, and an optional confirmed role set by users in `.repoimage.json`.
|
|
16
|
+
|
|
17
|
+
**Compression Score:** A 0–10 scale where higher scores indicate larger-than-expected file size relative to image dimensions. Used to identify poorly compressed assets.
|
|
18
|
+
|
|
19
|
+
**Partial Catalog:** The sidecar file (`.repoimage.json`) stores only manually confirmed images, not the full scan results. It's a user-curated subset.
|
|
20
|
+
|
|
21
|
+
**Path Exclusion:** Users can toggle exclusion of common dependency folders (`node_modules`, `dist`, `.git`, etc.) to focus on asset images.
|
|
22
|
+
|
|
23
|
+
## For Contributors
|
|
24
|
+
|
|
25
|
+
- Start with [Architecture](architecture.md) to understand the codebase structure
|
|
26
|
+
- See [PROJECT-AGENTS.md](../PROJECT-AGENTS.md) for project-specific development policies
|
|
27
|
+
- Review [design/invariants.md](design/invariants.md) for architectural constraints that must always hold
|
|
28
|
+
- Follow the testing and documentation requirements in [PROJECT-AGENTS.md](../PROJECT-AGENTS.md)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
RepoImage provides an HTTP API for scanning images, listing files, and serving image thumbnails. The API server runs on port 3847 (configurable via `PORT` env var).
|
|
4
|
+
|
|
5
|
+
## Endpoints
|
|
6
|
+
|
|
7
|
+
### Scan Endpoints
|
|
8
|
+
|
|
9
|
+
- **[POST /api/scan](scan.md)** — Scan one or more directories for images
|
|
10
|
+
- **POST /api/scan-entries** — Enrich client-provided rows with compression metrics
|
|
11
|
+
|
|
12
|
+
### File Browser
|
|
13
|
+
|
|
14
|
+
- **GET /api/fs/home** — Get user's home directory path
|
|
15
|
+
- **GET /api/fs/list** — List directory contents (directory browser)
|
|
16
|
+
- **POST /api/fs/list** — Alternative: send path in JSON body
|
|
17
|
+
|
|
18
|
+
### Image Serving
|
|
19
|
+
|
|
20
|
+
- **GET /api/file** — Serve an image file by absolute path
|
|
21
|
+
|
|
22
|
+
### Static Files
|
|
23
|
+
|
|
24
|
+
- **GET /** — Serve the React app (index.html and assets)
|
|
25
|
+
|
|
26
|
+
## Response Format
|
|
27
|
+
|
|
28
|
+
All endpoints return JSON. Successful responses include:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"images": [...], // or entries, or single result
|
|
33
|
+
"issues": [...] // if applicable
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Error responses:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"error": "string",
|
|
42
|
+
"detail": "optional detail message"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Security
|
|
47
|
+
|
|
48
|
+
- **Path validation** — All file paths are validated; `..` and escapes outside project root are rejected.
|
|
49
|
+
- **Loopback restriction** — Filesystem APIs (`/api/fs/*`, `/api/file`) are restricted to loopback clients (127.0.0.1, ::1) by default. Set `REPOIMAGE_FS_TRUST_REMOTE=1` to allow remote clients (not recommended).
|
|
50
|
+
|
|
51
|
+
## Environment Variables
|
|
52
|
+
|
|
53
|
+
| Variable | Default | Purpose |
|
|
54
|
+
|----------|---------|---------|
|
|
55
|
+
| `PORT` | `3847` | HTTP server port |
|
|
56
|
+
| `REPOIMAGE_BIND` | `127.0.0.1` | Bind address (e.g., `0.0.0.0` for all interfaces) |
|
|
57
|
+
| `REPOIMAGE_FS_TRUST_REMOTE` | (unset) | Set to `1` to allow non-loopback clients to use `/api/fs/*` |
|
|
58
|
+
| `REPOIMAGE_API_PORT` | `3847` | API port targeted by the Vite dev proxy |
|
|
59
|
+
|
|
60
|
+
## Future Endpoints
|
|
61
|
+
|
|
62
|
+
As features are implemented, new endpoints will be added:
|
|
63
|
+
|
|
64
|
+
- **POST /api/repoimage** — Save confirmed roles to `.repoimage.json`
|
|
65
|
+
- **GET /api/repoimage** — Load and validate config file
|