import {normalizeUrls} from './date-core.mjs'; import {collectImageUrls,readImageBytes,zipStore} from './page-tools-core.mjs'; import {imageDimensions,validateInput,INPUT_LIMITS} from './logo-core.mjs'; import {toolResponseError} from './tool-access.mjs'; export function imageCandidatesFromHtml(html,url){ const template=document.createElement('template');template.innerHTML=html;const doc=template.content; return collectImageUrls({url,baseHref:doc.querySelector('base[href]')?.getAttribute('href'), images:[...doc.querySelectorAll('img,source')].map(n=>({src:n.getAttribute('src'),alt:n.getAttribute('alt'),lazy:['data-src','data-lazy-src','data-original','data-url','data-image','data-lazy'].map(k=>n.getAttribute(k)),srcsets:[n.getAttribute('srcset'),n.getAttribute('data-srcset')]})), backgrounds:[...doc.querySelectorAll('[style]')].map(n=>n.getAttribute('style'))}); } for(const root of document.querySelectorAll('.aen-image-collector'))bind(root); function bind(root){ if(root.dataset.bound)return;root.dataset.bound='1';const find=s=>root.querySelector(s); const input=find('[data-url]'),run=find('[data-run]'),cancel=find('[data-cancel]'),collect=find('[data-collect]'),share=find('[data-share]'),refreshButton=find('[data-refresh]'),select=find('[data-select]'),clear=find('[data-clear]'); const status=find('[data-status]'),remaining=find('[data-remaining]'),reset=find('[data-reset]'),download=find('[data-download]'),choices=find('[data-choices]'),candidates=find('[data-candidates]'),results=find('[data-results]'); const endpoint=new URL(root.dataset.toolEndpoint),requests=new Set(),previewUrls=[]; let quota=null,refreshPromise=null,busy=false,phase='',generation=0,session=null,exportUrl='',files=[],outcomes=[]; const boxes=()=>[...candidates.querySelectorAll('input[type="checkbox"]')],selected=()=>boxes().filter(n=>n.checked); const cap=()=>Math.min(session?.max||20,quota?.tools?.images?.batch||20); function message(text,error=false){status.textContent=text;status.dataset.error=error?'1':'0';} function controls(){ run.disabled=busy||!!root.dataset.securityPending||!input.value.trim()||!quota?.tools?.images||quota.tools.images.remaining<1;input.disabled=busy;cancel.hidden=!busy;refreshButton.disabled=busy;share.disabled=busy||!!root.dataset.securityPending||!quota?.tools?.images||quota.share_claimed; const exhausted=!session||session.used;collect.disabled=busy||exhausted||!selected().length;select.disabled=clear.disabled=busy||exhausted;for(const box of boxes())box.disabled=busy||exhausted; find('[data-selection]').textContent=`${selected().length} selected · up to${cap()} in this run${session?.used?' · preparation attempt used':''}.`;select.textContent=`Select first${cap()}`;root.setAttribute('aria-busy',busy?'true':'false'); } function showQuota(data){quota=data;const p=data.tools?.images;if(!p){remaining.textContent='This theme has no active image collector.';controls();return;}remaining.textContent=`${p.remaining} of ${p.limit} runs remaining today`;reset.textContent=`Resets ${new Date(data.reset_at).toLocaleString(undefined,{timeZone:data.timezone})} (${data.timezone}).`;find('[data-batch]').textContent=`Up to${p.batch} selected images per run.`;share.textContent=data.share_claimed?'Daily bonus claimed':`Get ${p.share_bonus} extra use${p.share_bonus===1?'':'s'}`;controls();} async function request(path,body){ if(endpoint.origin!==location.origin)throw new Error('Open the collector at its canonical website address.'); const controller=new AbortController();requests.add(controller);const timer=setTimeout(()=>controller.abort(),15000); try{const options={cache:'no-store',credentials:'same-origin',signal:controller.signal};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-AEN-Tool-Token':quota?.token||''};options.body=JSON.stringify(body);}const response=await fetch(new URL(path,endpoint),options),data=await response.json();if(data.tools)showQuota(data);toolResponseError(root,endpoint,response,data);return data;} finally{clearTimeout(timer);requests.delete(controller);} } function refresh(){if(!refreshPromise)refreshPromise=request('status').finally(()=>{refreshPromise=null;});return refreshPromise;} root.addEventListener('aen-security-change',controls); root.addEventListener('aen-security-verified',()=>refresh().then(()=>message('Security check passed. You can continue; your daily allowance is unchanged.')).catch(e=>message(e.message,true))); async function sourceRead(url,reader){ const controller=new AbortController();requests.add(controller);let timeout=false;const timer=setTimeout(()=>{timeout=true;controller.abort();},15000); try{const response=await fetch(url,{mode:'cors',credentials:'omit',referrerPolicy:'no-referrer',cache:'no-store',signal:controller.signal});return{data:await reader(response),url:response.url||url};} catch(error){if(timeout)throw new Error('The source timed out after15 seconds.');if(error.name==='TypeError')throw new Error('Your browser could not read this source. Cross-origin rules, the connection or the source may prevent access.');throw error;} finally{clearTimeout(timer);requests.delete(controller);} } const el=(tag,text)=>{const node=document.createElement(tag);if(text!==undefined)node.textContent=text;return node;}; function clearExport(){if(exportUrl)URL.revokeObjectURL(exportUrl);exportUrl='';download.hidden=true;download.removeAttribute('href');} function clearSession(){session=null;files=[];outcomes=[];clearExport();for(const url of previewUrls)URL.revokeObjectURL(url);previewUrls.length=0;candidates.replaceChildren();results.replaceChildren();choices.hidden=true;} function renderCandidates(data,url){ session={url,items:data.items,used:false,max:quota.tools.images.batch};find('[data-source]').textContent=`Source: ${url}${data.truncated?' · first100 candidates shown':''}`; for(let i=0;i`${r.status}: ${r.url}${r.filename?' → '+r.filename:''}${r.message?' — '+r.message:''}`).join('\n'); const archive=zipStore([...files,{name:'source-notes.txt',data:new TextEncoder().encode(note)}]); exportUrl=URL.createObjectURL(new Blob([archive],{type:'application/zip'}));download.href=exportUrl;download.download='roadkitcraft-reference-images.zip';download.hidden=false; } async function normalize(bytes,ticket){ const header=imageDimensions(bytes);validateInput({size:bytes.length,count:1,...header}); if(typeof createImageBitmap!=='function')throw new Error('This browser does not support local image decoding.'); const bitmap=await createImageBitmap(new Blob([bytes],{type:header.mime}));let canvas; try{ if(ticket!==generation)return null;validateInput({size:bytes.length,count:1,width:bitmap.width,height:bitmap.height}); const scale=Math.min(1,INPUT_LIMITS.output/Math.max(bitmap.width,bitmap.height));canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale)); const ctx=canvas.getContext('2d');if(!ctx)throw new Error('Local image processing is unavailable.');ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';ctx.drawImage(bitmap,0,0,canvas.width,canvas.height); const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));if(ticket!==generation)return null;if(!blob||blob.type!=='image/png'||!blob.size)throw new Error('This image could not be exported as PNG.');return{blob,width:canvas.width,height:canvas.height}; }finally{bitmap.close();if(canvas){canvas.width=1;canvas.height=1;}} } run.addEventListener('click',async()=>{ if(busy)return;let url;try{[url]=normalizeUrls(input.value,1);}catch(error){message(error.message,true);return;} const ticket=++generation;busy=true;phase='scan';controls();message('Checking today’s allowance…'); try{ await refresh();if(ticket!==generation)return;const accepted=await request('run',{tool:'images',count:1});if(ticket!==generation)return;if(!accepted.accepted)throw new Error('The allowance could not be confirmed.'); clearSession();message('Reading public HTML; finding image references on your device…');const source=await request('html',{url,grant:accepted.html_grant});if(ticket!==generation)return;const page={url:source.url||url}; const data=imageCandidatesFromHtml(source.html,page.url);if(!data.items.length){message('The page was read, but no supported image references were found in its returned HTML.');return;} renderCandidates(data,page.url);message(`Found ${data.items.length}${data.truncated?'+':''} image candidates. Select what to prepare; images have not been downloaded yet.`); }catch(error){if(ticket===generation)message(error.name==='AbortError'?'Interrupted. A scan that already started still counts.':error.message||'This page could not be read.',true);} finally{if(ticket===generation){busy=false;phase='';controls();}} }); collect.addEventListener('click',async()=>{ if(busy||!session||session.used)return; const indexes=selected().map(n=>Number(n.dataset.index));if(!indexes.length||indexes.length>cap()){message(`Select one to${cap()} images.`,true);return;} const ticket=++generation;busy=true;phase='collect';session.used=true;files=[];outcomes=[];results.replaceChildren();clearExport();controls();let outputBytes=0; try{ for(let i=0;i39*1024*1024)throw new Error('Not collected: the40 MiB collection budget would be exceeded. Choose a smaller batch on the next run.'); const bytes=new Uint8Array(await output.blob.arrayBuffer());if(ticket!==generation)return; const filename=`image-${String(i+1).padStart(2,'0')}.png`;files.push({name:filename,data:bytes});outputBytes+=bytes.length; Object.assign(outcome,{status:'Ready',filename,message:`${output.width} ×${output.height}px PNG`});state.textContent=outcome.message; const preview=el('img');const previewUrl=URL.createObjectURL(output.blob);previewUrls.push(previewUrl);preview.src=previewUrl;preview.alt=item.alt||'Collected image preview';preview.width=160;preview.height=110;row.append(preview); }catch(error){if(ticket!==generation)return;outcome.message=error.message||'This image could not be prepared.';state.textContent=outcome.message;state.dataset.error='1';} outcomes.push(outcome);await new Promise(requestAnimationFrame);if(ticket!==generation)return; } makeExport();message(`Finished: ${files.length} prepared, ${outcomes.length-files.length} not collected.${files.length?' Download the ZIP with image files and source notes.':' No image files were exported.'}`); }catch(error){if(ticket===generation)message(error.message||'The collection could not be exported.',true);} finally{if(ticket===generation){busy=false;phase='';controls();}} }); cancel.addEventListener('click',()=>{const wasCollecting=phase==='collect';generation++;for(const controller of requests)controller.abort();busy=false;phase='';if(wasCollecting){for(const row of results.querySelectorAll('.aen-collector-result p'))if(row.textContent==='Reading…')row.textContent='Cancelled before this image was prepared.';try{makeExport();}catch(error){message(error.message,true);controls();return;}}controls();message(`Cancelled. A scan that already started still counts.${wasCollecting&&files.length?' Completed images are available in a partial ZIP.':''} Start a new scan for another preparation attempt.`);}); candidates.addEventListener('change',event=>{if(event.target.matches('input[type="checkbox"]')&&selected().length>cap()){event.target.checked=false;message(`Select no more than${cap()} images in this run.`,true);}controls();}); select.addEventListener('click',()=>{for(const [i,box]of boxes().entries())box.checked=i{for(const box of boxes())box.checked=false;controls();});input.addEventListener('input',controls); refreshButton.addEventListener('click',()=>refresh().then(()=>message('Daily allowance refreshed.')).catch(e=>message(e.message||'Allowance unavailable.',true))); share.addEventListener('click',async()=>{if(busy||quota?.share_claimed)return;if(navigator.share)navigator.share({title:document.title,url:root.dataset.home}).catch(()=>{});else navigator.clipboard?.writeText(root.dataset.home).catch(()=>{});const ticket=++generation;busy=true;phase='bonus';controls();message('Checking the daily bonus…');try{await refresh();if(ticket!==generation)return;const data=await request('share',{});if(ticket===generation)message(data.bonus_granted?'Daily bonus added. Use your share menu or copied website link to share.':'Today’s bonus was already claimed.');}catch(e){if(ticket===generation)message(e.message||'The bonus could not be confirmed.',true);}finally{if(ticket===generation){busy=false;phase='';controls();}}}); addEventListener('pagehide',event=>{if(event.persisted)return;generation++;for(const controller of requests)controller.abort();clearSession();}); addEventListener('pageshow',event=>{if(event.persisted)refresh().catch(e=>message(e.message||'Refresh the allowance.',true));}); message('Enter a page URL to find its image references.');refresh().catch(e=>{remaining.textContent='Daily allowance unavailable';message(e.message||'Refresh the allowance.',true);controls();}); }