Skip to content

Commit 5b3866c

Browse files
authored
Capture direct image URLs as image cards (#214)
* Capture direct image URLs as images * Harden direct image response handling
1 parent 9124075 commit 5b3866c

2 files changed

Lines changed: 180 additions & 4 deletions

File tree

‎packages/web/src/lib/capture.test.ts‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ const { enrichBookmark } = vi.hoisted(() => ({
2424
}));
2525
vi.mock('./enrich', () => ({ enrichBookmark }));
2626

27-
import { captureDetected, captureFile } from './capture';
27+
import { captureDetected, captureFile, downloadDirectImage } from './capture';
2828

29-
afterEach(() => vi.clearAllMocks());
29+
afterEach(() => {
30+
vi.clearAllMocks();
31+
vi.unstubAllGlobals();
32+
});
3033

3134
describe('captureDetected', () => {
3235
it('stores a bookmark for a URL', async () => {
@@ -38,6 +41,92 @@ describe('captureDetected', () => {
3841
);
3942
});
4043

44+
it('downloads and stores a direct image URL as an image card', async () => {
45+
const imageBytes = new Uint8Array([1, 2, 3]);
46+
vi.stubGlobal(
47+
'fetch',
48+
vi.fn().mockResolvedValue(
49+
new Response(imageBytes, {
50+
status: 200,
51+
headers: {
52+
'content-type': 'IMAGE/JPEG; charset=binary',
53+
'content-length': String(imageBytes.byteLength),
54+
},
55+
}),
56+
),
57+
);
58+
59+
const url = 'https://cdn.example.com/photos/rolling%20stone.jpg?width=1200';
60+
const res = await captureDetected(url);
61+
62+
expect(res?.item).toMatchObject({
63+
type: 'image',
64+
title: 'rolling stone.jpg',
65+
mimeType: 'image/jpeg',
66+
sourceUrl: url,
67+
});
68+
expect(storeItem.mock.calls[0][1]).toBeInstanceOf(ArrayBuffer);
69+
expect(enrichBookmark).not.toHaveBeenCalled();
70+
});
71+
72+
it('normalizes an uppercase image MIME type', async () => {
73+
vi.stubGlobal(
74+
'fetch',
75+
vi.fn().mockResolvedValue(
76+
new Response(new Uint8Array([1]), {
77+
headers: { 'content-type': 'IMAGE/JPEG' },
78+
}),
79+
),
80+
);
81+
82+
const file = await downloadDirectImage('https://example.com/photo.jpg');
83+
84+
expect(file).toBeInstanceOf(File);
85+
expect(file?.type).toBe('image/jpeg');
86+
});
87+
88+
it('cancels an image stream when an unannounced body exceeds 25 MB', async () => {
89+
const cancel = vi.fn();
90+
const stream = new ReadableStream<Uint8Array>({
91+
start(controller) {
92+
controller.enqueue(new Uint8Array(20 * 1024 * 1024));
93+
controller.enqueue(new Uint8Array(6 * 1024 * 1024));
94+
},
95+
cancel,
96+
});
97+
vi.stubGlobal(
98+
'fetch',
99+
vi.fn().mockResolvedValue(
100+
new Response(stream, {
101+
headers: { 'content-type': 'image/jpeg' },
102+
}),
103+
),
104+
);
105+
106+
const file = await downloadDirectImage('https://example.com/large.jpg');
107+
108+
expect(file).toBeNull();
109+
expect(cancel).toHaveBeenCalledOnce();
110+
});
111+
112+
it('falls back to a bookmark when an image-looking URL is not an image', async () => {
113+
vi.stubGlobal(
114+
'fetch',
115+
vi.fn().mockResolvedValue(
116+
new Response('<html>not found</html>', {
117+
status: 200,
118+
headers: { 'content-type': 'text/html' },
119+
}),
120+
),
121+
);
122+
123+
const url = 'https://example.com/not-really.jpg';
124+
const res = await captureDetected(url);
125+
126+
expect(res?.item.type).toBe('bookmark');
127+
expect(enrichBookmark).toHaveBeenCalledWith(res?.item);
128+
});
129+
41130
it('kicks off background metadata enrichment for bookmarks', async () => {
42131
const res = await captureDetected('https://example.com');
43132
expect(enrichBookmark).toHaveBeenCalledOnce();

‎packages/web/src/lib/capture.ts‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,78 @@ import { detectCaptureKind } from './capture-detect';
1010
import { enrichBookmark } from './enrich';
1111
import { collections, moveItemToCollection, storeItem } from './stores';
1212

13+
const MAX_DIRECT_IMAGE_BYTES = 25 * 1024 * 1024;
14+
const DIRECT_IMAGE_TIMEOUT_MS = 20_000;
15+
const IMAGE_PATH_PATTERN = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i;
16+
17+
/**
18+
* Download an apparent direct-image URL for local-first storage. The file
19+
* extension is only a cheap candidate filter; the response Content-Type is
20+
* authoritative. Any CORS/network/type/size failure falls back to a bookmark.
21+
*/
22+
export async function downloadDirectImage(url: string): Promise<File | null> {
23+
let parsed: URL;
24+
try {
25+
parsed = new URL(url);
26+
} catch {
27+
return null;
28+
}
29+
if (!IMAGE_PATH_PATTERN.test(parsed.pathname)) return null;
30+
31+
const controller = new AbortController();
32+
const timeout = setTimeout(() => controller.abort(), DIRECT_IMAGE_TIMEOUT_MS);
33+
try {
34+
const response = await fetch(url, { signal: controller.signal });
35+
if (!response.ok) return null;
36+
37+
const mimeType = response.headers
38+
.get('content-type')
39+
?.split(';')[0]
40+
.trim()
41+
.toLowerCase();
42+
if (!mimeType?.startsWith('image/')) return null;
43+
44+
const length = response.headers.get('content-length')?.trim();
45+
if (
46+
length &&
47+
/^\d+$/.test(length) &&
48+
Number(length) > MAX_DIRECT_IMAGE_BYTES
49+
) {
50+
return null;
51+
}
52+
53+
if (!response.body) return null;
54+
const reader = response.body.getReader();
55+
const chunks: ArrayBuffer[] = [];
56+
let receivedBytes = 0;
57+
while (true) {
58+
const { done, value } = await reader.read();
59+
if (done) break;
60+
receivedBytes += value.byteLength;
61+
if (receivedBytes > MAX_DIRECT_IMAGE_BYTES) {
62+
await reader.cancel();
63+
return null;
64+
}
65+
const chunk = new Uint8Array(value.byteLength);
66+
chunk.set(value);
67+
chunks.push(chunk.buffer);
68+
}
69+
const blob = new Blob(chunks, { type: mimeType });
70+
const encodedName = parsed.pathname.split('/').pop() || 'image';
71+
let filename = encodedName;
72+
try {
73+
filename = decodeURIComponent(encodedName);
74+
} catch {
75+
// Keep the encoded URL segment when it contains malformed escapes.
76+
}
77+
return new File([blob], filename, { type: mimeType });
78+
} catch {
79+
return null;
80+
} finally {
81+
clearTimeout(timeout);
82+
}
83+
}
84+
1385
/** Detect, build and store a quick capture. Returns the created item so the
1486
* caller can offer Undo; null when there's nothing to capture. When a
1587
* collectionId is given (capturing from a collection view), the item is filed
@@ -31,16 +103,31 @@ export async function captureDetected(
31103
throw new Error('Target collection no longer exists');
32104
}
33105

34-
const built =
106+
const directImage =
35107
detected.kind === 'bookmark'
108+
? await downloadDirectImage(detected.url)
109+
: null;
110+
const built = directImage
111+
? await buildImageItem(ctx, {
112+
title: directImage.name,
113+
description: '',
114+
file: directImage,
115+
})
116+
: detected.kind === 'bookmark'
36117
? buildBookmarkItem(ctx, {
37118
url: detected.url,
38119
title: '',
39120
description: '',
40121
})
41122
: buildNoteItem(ctx, { title: '', body: detected.body, description: '' });
42123

43-
await storeItem(built.item);
124+
if (!built) return null;
125+
if (directImage && built.item.type === 'image') {
126+
built.item.sourceUrl =
127+
detected.kind === 'bookmark' ? detected.url : undefined;
128+
}
129+
130+
await storeItem(built.item, built.fileData, built.thumbData);
44131
if (collectionId) {
45132
await moveItemToCollection(built.item.id, collectionId);
46133
}

0 commit comments

Comments
 (0)