@@ -10,6 +10,78 @@ import { detectCaptureKind } from './capture-detect';
1010import { enrichBookmark } from './enrich' ;
1111import { 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 = / \. (?: a v i f | g i f | j p e ? g | p n g | s v g | w e b p ) $ / 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