Ctrl K
ComunidadGuías…

chrome-extension/popup.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const $ = selector => document.querySelector(selector);
const messages = {
  en: { connectTitle:'Connect to your NAS', connectHint:'Use the same address and account as the NASDrop web portal.', serverAddress:'NASDrop address', username:'ID', password:'Password', autoExtract:'Automatically extract archives', connect:'Connect', logout:'Sign out', downloadLink:'Download link', useCurrentTab:'Use current tab', send:'Send to NASDrop', recentJobs:'Recent jobs', refresh:'Refresh', destination:'Destination', noDestination:'Not selected in NASDrop', noJobs:'No download jobs yet.', connecting:'Connecting…', inspecting:'Checking the link and adding it…', detected:'{provider} link detected. Ready to send.', added:'Added: {name}', addedMany:'Added {count} files.', currentTabUnavailable:'This tab does not contain a supported download link.', permissionDenied:'Permission to connect to this NASDrop address is required.', passwordChangeRequired:'Open the NASDrop web portal and change the temporary NASDrop ID and password first.' },
  ko: { connectTitle:'NAS에 연결', connectHint:'NASDrop 웹 포털과 같은 주소 및 계정을 사용하세요.', serverAddress:'NASDrop 주소', username:'아이디', password:'비밀번호', autoExtract:'압축 파일 자동 해제', connect:'연결', logout:'로그아웃', downloadLink:'다운로드 링크', useCurrentTab:'현재 탭 주소 사용', send:'NASDrop으로 보내기', recentJobs:'최근 작업', refresh:'새로고침', destination:'저장 위치', noDestination:'NASDrop에서 선택되지 않음', noJobs:'아직 다운로드 작업이 없습니다.', connecting:'연결 중…', inspecting:'링크를 확인하고 추가하는 중…', detected:'{provider} 링크를 인식했습니다. 바로 보낼 수 있습니다.', added:'추가됨: {name}', addedMany:'파일 {count}개를 추가했습니다.', currentTabUnavailable:'현재 탭에서 지원되는 다운로드 링크를 찾지 못했습니다.', permissionDenied:'이 NASDrop 주소에 연결할 권한이 필요합니다.', passwordChangeRequired:'NASDrop 웹 포털에서 임시 NASDrop 아이디와 비밀번호를 먼저 변경하세요.' },
  ja: { connectTitle:'NASに接続', connectHint:'NASDropウェブポータルと同じアドレスとアカウントを使用します。', serverAddress:'NASDropアドレス', username:'ID', password:'パスワード', autoExtract:'アーカイブを自動展開', connect:'接続', logout:'ログアウト', downloadLink:'ダウンロードリンク', useCurrentTab:'現在のタブを使用', send:'NASDropへ送信', recentJobs:'最近のジョブ', refresh:'更新', destination:'保存先', noDestination:'NASDropで未選択', noJobs:'ダウンロードジョブはまだありません。', connecting:'接続中…', inspecting:'リンクを確認して追加中…', detected:'{provider}リンクを検出しました。送信できます。', added:'追加済み: {name}', addedMany:'{count}件のファイルを追加しました。', currentTabUnavailable:'現在のタブに対応するダウンロードリンクがありません。', permissionDenied:'このNASDropアドレスへの接続権限が必要です。', passwordChangeRequired:'NASDropウェブポータルで一時的なNASDrop IDとパスワードを先に変更してください。' },
  zh: { connectTitle:'连接到 NAS', connectHint:'使用与 NASDrop 网页门户相同的地址和账户。', serverAddress:'NASDrop 地址', username:'ID', password:'密码', autoExtract:'自动解压缩文件', connect:'连接', logout:'退出', downloadLink:'下载链接', useCurrentTab:'使用当前标签页', send:'发送到 NASDrop', recentJobs:'最近任务', refresh:'刷新', destination:'保存位置', noDestination:'尚未在 NASDrop 中选择', noJobs:'还没有下载任务。', connecting:'正在连接…', inspecting:'正在检查链接并添加…', detected:'已识别 {provider} 链接,可以发送。', added:'已添加:{name}', addedMany:'已添加 {count} 个文件。', currentTabUnavailable:'当前标签页没有受支持的下载链接。', permissionDenied:'需要连接此 NASDrop 地址的权限。', passwordChangeRequired:'请先在NASDrop网页门户中更改临时NASDrop ID和密码。' },
};
messages.ko.autoExtract = '자동 압축 풀기';
let language = NASDropI18n.normalize(navigator.language);
Object.assign(messages.en, {clearCompleted:'Clear completed',archivePassword:'Archive password (optional)',close:'Close',saveOptions:'Save options',clearConfirm:'Remove completed jobs from the list? Files on the NAS will be kept.',cleared:'Completed jobs removed. NAS files were kept.',saved:'Options saved.',passwordAlert:'{count} job(s) need an archive password. Select a job below.',live:'Auto refresh · {time}',retry:'Connection interrupted · retrying with a longer interval',locked:'Options cannot be changed during processing or after completion.',upgrade:'Changing extraction options requires a newer NASDrop server. Password-waiting jobs can still accept a password.',optionHint:'Leave password empty to keep the existing password. Paused jobs remain paused.',passwordNeeded:'Enter the archive password to continue.'});
Object.assign(messages.ko, {clearCompleted:'완료된 작업 삭제',archivePassword:'압축 비밀번호 (선택)',close:'닫기',saveOptions:'설정 저장',clearConfirm:'완료된 작업을 목록에서 삭제할까요? NAS에 저장된 파일은 유지됩니다.',cleared:'완료된 작업 기록을 삭제했습니다. NAS 파일은 유지됩니다.',saved:'설정을 저장했습니다.',passwordAlert:'작업 {count}개에 압축 비밀번호가 필요합니다. 아래 작업을 눌러 입력하세요.',live:'자동 갱신 · {time}',retry:'연결 지연 · 간격을 늘려 다시 확인합니다',locked:'후처리 중이거나 종료된 작업은 설정을 변경할 수 없습니다.',upgrade:'압축 해제 설정 변경은 새 NASDrop 서버가 필요합니다. 비밀번호 대기 작업의 암호 입력은 가능합니다.',optionHint:'비밀번호를 비우면 기존 암호를 유지합니다. 일시정지한 작업은 자동 재개하지 않습니다.',passwordNeeded:'계속하려면 압축 비밀번호를 입력하세요.'});
Object.assign(messages.ja, {clearCompleted:'完了履歴を削除',archivePassword:'アーカイブのパスワード(任意)',close:'閉じる',saveOptions:'設定を保存',clearConfirm:'完了した履歴を削除しますか?NASのファイルは保持されます。',cleared:'完了履歴を削除しました。ファイルは保持されます。',saved:'設定を保存しました。',passwordAlert:'{count}件にパスワードが必要です。ジョブを選択してください。',live:'自動更新 · {time}',retry:'接続できません。間隔を延ばして再試行します。',locked:'処理中または完了後は変更できません。',upgrade:'展開設定の変更には新しいNASDropサーバーが必要です。',optionHint:'空欄なら既存のパスワードを保持します。一時停止は自動再開しません。',passwordNeeded:'アーカイブのパスワードを入力してください。'});
Object.assign(messages.zh, {clearCompleted:'清除已完成任务',archivePassword:'压缩密码(可选)',close:'关闭',saveOptions:'保存设置',clearConfirm:'清除已完成任务记录?NAS 文件将保留。',cleared:'已清除完成记录,NAS 文件已保留。',saved:'设置已保存。',passwordAlert:'{count} 个任务需要密码,请点击任务输入。',live:'自动刷新 · {time}',retry:'连接中断,正在延长间隔重试',locked:'处理期间或结束后不能更改设置。',upgrade:'更改解压设置需要新版 NASDrop 服务。',optionHint:'留空以保留现有密码。暂停任务不会自动恢复。',passwordNeeded:'请输入压缩密码以继续。'});
Object.assign(messages.en,{gigafileDownloadKey:'GigaFile download key (optional)',gigafileDownloadKeyRequired:'GigaFile download key',gigafileDownloadKeyHint:'Used only for a protected GigaFile download. It is not an archive password.',gigafileDownloadKeyRequiredHint:'Enter the 1–4 character key supplied by the sender.',submitDownloadKey:'Submit download key',invalidDownloadKey:'Enter a GigaFile download key containing 1–4 characters.'});
Object.assign(messages.ko,{gigafileDownloadKey:'GigaFile 다운로드 키 (선택)',gigafileDownloadKeyRequired:'GigaFile 다운로드 키',gigafileDownloadKeyHint:'키가 설정된 GigaFile 다운로드에만 사용합니다. 압축 비밀번호와는 별개입니다.',gigafileDownloadKeyRequiredHint:'보낸 사람이 제공한 1~4자 키를 입력하세요.',submitDownloadKey:'다운로드 키 제출',invalidDownloadKey:'1~4자의 GigaFile 다운로드 키를 입력하세요.'});
Object.assign(messages.ja,{gigafileDownloadKey:'GigaFileダウンロードキー(任意)',gigafileDownloadKeyRequired:'GigaFileダウンロードキー',gigafileDownloadKeyHint:'保護されたGigaFileのダウンロードにのみ使用します。アーカイブのパスワードとは別です。',gigafileDownloadKeyRequiredHint:'送信者から提供された1~4文字のキーを入力してください。',submitDownloadKey:'ダウンロードキーを送信',invalidDownloadKey:'1~4文字のGigaFileダウンロードキーを入力してください。'});
Object.assign(messages.zh,{gigafileDownloadKey:'GigaFile 下载密钥(可选)',gigafileDownloadKeyRequired:'GigaFile 下载密钥',gigafileDownloadKeyHint:'仅用于受保护的 GigaFile 下载,与压缩密码不同。',gigafileDownloadKeyRequiredHint:'请输入发送者提供的 1–4 个字符密钥。',submitDownloadKey:'提交下载密钥',invalidDownloadKey:'请输入 1–4 个字符的 GigaFile 下载密钥。'});
Object.assign(messages.en,{pauseJob:'Pause',resumeJob:'Resume',deleteJob:'Delete',deleteRecord:'Delete record',deleteHint:'Pause first to delete unfinished files. Completed files are kept.',stoppingHint:'Stopping… Delete becomes available after the worker exits.',deletePartialConfirm:'Delete this unfinished job and all its temporary download files? This cannot be undone. Completed output files are kept.',deleteRecordConfirm:'Remove this completed job record? The downloaded files will be kept.',jobActionDone:'Request accepted.',jobDeleted:'Job deleted.'});
Object.assign(messages.ko,{pauseJob:'멈추기',resumeJob:'다시 시작',deleteJob:'삭제',deleteRecord:'기록 삭제',deleteHint:'미완료 작업은 멈춘 뒤 삭제하면 받던 임시 파일도 삭제됩니다. 완료 파일은 유지됩니다.',stoppingHint:'중지 중… 작업이 완전히 멈추면 삭제할 수 있습니다.',deletePartialConfirm:'이 미완료 작업과 받던 임시 파일을 삭제할까요? 되돌릴 수 없습니다. 이미 완료된 출력 파일은 유지됩니다.',deleteRecordConfirm:'완료된 작업의 기록을 삭제할까요? NAS에 저장된 파일은 유지됩니다.',jobActionDone:'요청을 접수했습니다.',jobDeleted:'작업을 삭제했습니다.'});
Object.assign(messages.ja,{pauseJob:'停止',resumeJob:'再開',deleteJob:'削除',deleteRecord:'履歴削除',deleteHint:'停止後に削除すると一時ファイルも削除されます。完了ファイルは保持されます。',stoppingHint:'停止処理中です。終了後に削除できます。',deletePartialConfirm:'未完了のジョブと一時ファイルを削除しますか?元に戻せません。完了ファイルは保持されます。',deleteRecordConfirm:'履歴を削除しますか?NASのファイルは保持されます。',jobActionDone:'リクエストを受け付けました。',jobDeleted:'ジョブを削除しました。'});
Object.assign(messages.zh,{pauseJob:'暂停',resumeJob:'继续',deleteJob:'删除',deleteRecord:'删除记录',deleteHint:'暂停后删除会清除临时文件。已完成的文件将保留。',stoppingHint:'正在停止,结束后可以删除。',deletePartialConfirm:'删除此未完成任务及临时文件?无法撤销,已完成文件将保留。',deleteRecordConfirm:'删除记录?NAS 文件将保留。',jobActionDone:'请求已接受。',jobDeleted:'任务已删除。'});
let uiState = {connected:false,jobs:[]}, selectedJob = '', optionsDirty = false, mutationBusy = false, loginBusy = false;
const jobCards=new Map(), jobDetail=$('#job-detail');
const pausableStates=new Set(['inspecting','queued','ready','downloading','waiting_processing','verifying']);
const resumableStates=new Set(['paused','failed','cancelled']);
const deletableStates=new Set(['paused','failed','cancelled','password_required','download_key_required','completed']);
const editableStates = new Set(['queued','ready','inspecting','paused','downloading','password_required']);
const popupPort = chrome.runtime.connect({name:'nasdrop-popup'});
const polling = NASDropPolling.create({
  load:() => send({type:'getJobs'}), visible:() => !document.hidden,
  onData:result => { uiState.jobs = result.jobs; renderJobs(result.jobs); syncJobDetail(); $('#poll-status').textContent = t('live',{time:new Date().toLocaleTimeString(language)}); },
  onError:error => {
    $('#poll-status').textContent = t('retry');
    if (error.status === 401) { render({...uiState,connected:false,token:''}); notice(error.message); }
  },
});
const t = (key, vars = {}) => Object.entries(vars).reduce((text, [name, value]) => text.replace(`{${name}}`, value), messages[language][key] || messages.en[key] || key);

function localize() {
  document.documentElement.lang = language;
  $('#language-label').textContent=NASDropI18n.t(language,'language');
  $('#language-auto').textContent=NASDropI18n.t(language,'autoLanguage');
  $('#destination-label').textContent=`${t('destination')}: ${uiState.status?.target || t('noDestination')}`;
  document.querySelectorAll('[data-i18n]').forEach(node => { node.textContent = t(node.dataset.i18n); });
  const automatic = {
    en:['Send from supported sites', "Click a site's download button to send it to NASDrop.", 'Send a link manually', 'Support NASDrop on Buy Me a Coffee'],
    ko:['사이트에서 바로 보내기', '다운로드 버튼을 누르면 NASDrop으로 전송됩니다.', '링크 직접 입력', 'Buy Me a Coffee에서 NASDrop 후원하기'],
    ja:['対応サイトから送信', 'ダウンロードボタンを押すとNASDropへ送信します。', 'リンクを手動入力', 'Buy Me a CoffeeでNASDropを支援'],
    zh:['从支持的网站发送', '点击下载按钮即可发送到 NASDrop。', '手动输入链接', '在 Buy Me a Coffee 上支持 NASDrop'],
  }[language];
  ['automatic-title','automatic-hint','manual-title'].forEach((id,index) => { document.getElementById(id).textContent = automatic[index]; });
  $('#sponsor-banner').setAttribute('aria-label',automatic[3]);
}
function notice(text = '', success = false) { $('#notice').textContent = text ? NASDropI18n.error(language,text) : ''; $('#notice').classList.toggle('success', success); }
function send(message) { return chrome.runtime.sendMessage(message).then(response => { if (!response?.ok) { const error = new Error(NASDropI18n.error(language,response?.error)); error.status=response?.status; throw error; } return response.result; }); }
function permissionPattern(rawUrl) { let url; try {url=new URL(rawUrl);} catch {throw new Error(NASDropI18n.t(language,'invalidAddress'));} if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error(NASDropI18n.t(language,'invalidAddress')); return `${url.protocol}//${url.hostname}/*`; }
function percent(job) { return job.size > 0 ? Math.min(100, Math.round((job.downloaded || 0) * 100 / job.size)) : 0; }
function supportedProvider(rawUrl) {
  try {
    const url = new URL(rawUrl);
    const host = url.hostname.toLowerCase();
    if (url.protocol !== 'https:') return '';
    if (/^[a-z0-9-]+\.gigafile\.nu$/.test(host) && /^\/[A-Za-z0-9_-]+\/?$/.test(url.pathname)) return 'GigaFile';
    if (['gofile.io', 'www.gofile.io'].includes(host) && url.pathname.startsWith('/d/')) return 'GoFile';
    if (['pixeldrain.com', 'www.pixeldrain.com', 'pixeldrain.net', 'pixeldra.in'].includes(host) && url.pathname.startsWith('/u/')) return 'Pixeldrain';
    if (/^[a-z0-9-]+\.buzzheavier\.com$/.test(host) && url.pathname.startsWith('/d/') && url.searchParams.has('v')) return 'Buzzheavier';
  } catch (_) {}
  return '';
}

async function useCurrentTab(showError = true) {
  const [tab] = await chrome.tabs.query({ active:true, currentWindow:true });
  const provider = supportedProvider(tab?.url || '');
  if (!provider) { if (showError) notice(t('currentTabUnavailable')); return false; }
  $('#download-url').value = tab.url;
  notice(t('detected', { provider }), true);
  return true;
}

function renderJobs(jobs) {
  const list=$('#jobs');
  $('#clear-completed').disabled=mutationBusy || !jobs?.some(job=>job.status==='completed');
  const waiting=jobs?.filter(job=>job.status==='password_required').length || 0;
  $('#password-alert').classList.toggle('hidden',!waiting);
  $('#password-alert').textContent=waiting?t('passwordAlert',{count:waiting}):'';
  for(const [id,card] of jobCards) if(!jobs?.some(job=>job.id===id)) {
    if(jobDetail.parentNode===card.item) $('#app-view').append(jobDetail);
    card.item.remove(); jobCards.delete(id);
  }
  if(!jobs?.length) { list.replaceChildren(); const empty=document.createElement('div'); empty.className='empty'; empty.textContent=t('noJobs'); list.append(empty); return; }
  if(!jobCards.size) list.replaceChildren();
  jobs.forEach(job=>{
    let card=jobCards.get(job.id);
    if(!card) {
      const item=document.createElement('div');
      const summary=document.createElement('button'); summary.type='button'; summary.className='job-summary';
      summary.addEventListener('click',()=>openJob(job.id));
      const line=document.createElement('div'); line.className='job-line';
      const name=document.createElement('strong'), status=document.createElement('span');
      const bar=document.createElement('div'); bar.className='bar';
      const fill=document.createElement('i'); bar.append(fill);
      const meta=document.createElement('div'); meta.className='job-meta';
      const controls=document.createElement('div'); controls.className='job-controls';
      const buttons=['jobPause','jobResume','jobDelete'].map(type=>{
        const button=document.createElement('button'); button.type='button';
        button.className=type==='jobDelete'?'text-button danger':'secondary';
        button.addEventListener('click',()=>runJobAction(type,null,job.id)); controls.append(button); return button;
      });
      line.append(name,status); summary.append(line,bar,meta); item.append(summary,controls); list.append(item);
      card={item,summary,name,status,fill,meta,controls,buttons}; jobCards.set(job.id,card);
    }
    const expanded=selectedJob===job.id;
    card.item.className='job'+(expanded?' selected':'')+(job.status==='password_required'?' password-required':'');
    card.summary.setAttribute('aria-expanded',String(expanded));
    card.name.textContent=job.name;
    card.status.textContent=NASDropI18n.t(language,job.status==='failed'?'failedStatus':job.status)+' · '+percent(job)+'%';
    card.fill.style.width=percent(job)+'%';
    card.meta.textContent=formatBytes(job.downloaded)+' / '+formatBytes(job.size);
    card.controls.classList.toggle('hidden',!expanded);
    for(const [i,key,states] of [[0,'pauseJob',pausableStates],[1,'resumeJob',resumableStates],[2,job.status==='completed'?'deleteRecord':'deleteJob',deletableStates]]) {
      card.buttons[i].textContent=t(key); card.buttons[i].disabled=mutationBusy||!states.has(job.status);
      card.buttons[i].setAttribute('aria-label',t(key)+': '+job.name);
    }
    if(expanded && jobDetail.parentNode!==card.item) card.item.append(jobDetail);
  });
}

function formatBytes(value) {
  if (!Number.isFinite(value) || value <= 0) return '0 B';
  const unit = Math.min(4,Math.floor(Math.log(value)/Math.log(1024)));
  return `${(value/1024**unit).toFixed(unit ? 1 : 0)} ${['B','KB','MB','GB','TB'][unit]}`;
}
function openJob(id) {
  if(mutationBusy) return;
  if(selectedJob===id) { closeJob(); return; }
  if (selectedJob !== id) { selectedJob=id; optionsDirty=false; $('#job-password').value=''; $('#job-download-key').value=''; }
  jobDetail.classList.remove('hidden'); renderJobs(uiState.jobs); syncJobDetail();
}
function closeJob() { selectedJob=''; optionsDirty=false; $('#job-password').value=''; $('#job-download-key').value=''; jobDetail.classList.add('hidden'); renderJobs(uiState.jobs); }
function syncJobDetail() {
  const job=uiState.jobs.find(job=>job.id===selectedJob);
  if (!job) { closeJob(); return; }
  $('#job-delete-hint').textContent=t(job.status==='stopping'?'stoppingHint':'deleteHint');
  const modern=Boolean(uiState.status?.job_processing_options), editable=editableStates.has(job.status);
  const downloadKeyRequired=job.status==='download_key_required';
  $('#job-options-form').classList.toggle('hidden',downloadKeyRequired);
  $('#job-download-key-form').classList.toggle('hidden',!downloadKeyRequired);
  $('#job-download-key').disabled=mutationBusy || !downloadKeyRequired || uiState.status?.gigafile_download_key !== true;
  $('#save-job-download-key').disabled=$('#job-download-key').disabled;
  $('#job-password').disabled=mutationBusy || !job.extract || !(modern && editable || job.status==='password_required');
  $('#save-job-options').disabled=$('#job-password').disabled;
  $('#job-options-hint').textContent=!modern ? t('upgrade') : !editable ? t('locked') : job.status==='password_required' ? t('passwordNeeded') : t('optionHint');
}

function render(state) {
  uiState=state;
  language=NASDropI18n.resolve(state.language,navigator.language);
  $('#language-select').value=state.language||'auto';
  localize();
  const connected = Boolean(state.connected);
  $('#login-view').classList.toggle('hidden', connected);
  $('#app-view').classList.toggle('hidden', !connected);
  $('#logout-button').classList.toggle('hidden', !connected);
  $('#base-url').value = state.baseUrl || $('#base-url').value;
  $('#username').value = state.username || $('#username').value;
  $('#auto-extract').checked = state.autoExtract !== false;
  $('#archive-password').disabled = state.autoExtract === false;
  $('#gigafile-download-key').disabled = state.status?.gigafile_download_key !== true;
  $('#connection-label').textContent = connected ? state.baseUrl : 'Chrome';
  if (connected) {
    $('#destination-label').textContent = `${t('destination')}: ${state.status?.target || t('noDestination')}`;
    renderJobs(state.jobs);
    syncJobDetail();
    polling.start(false);
  } else { polling.stop(); closeJob(); if (state.error) notice(state.error); }
  if (state.passwordChangeRequired) notice(t('passwordChangeRequired'));
}

async function refresh() { render(await send({ type: 'getState' })); }

$('#login-form').addEventListener('submit', async event => {
  event.preventDefault(); if (loginBusy) return; loginBusy=true;
  const button=$('#login-button'); button.disabled=true; notice(t('connecting'));
  const baseUrl = $('#base-url').value.trim();
  try {
    const origin=permissionPattern(baseUrl);
    const granted=await chrome.permissions.request({origins:[origin]});
    if (!granted) throw new Error(t('permissionDenied'));
    const state = await send({ type:'login', baseUrl, username:$('#username').value, password:$('#password').value });
    $('#password').value = ''; notice(''); render(state);
  } catch (error) { notice(error.message); } finally { button.disabled = false; loginBusy=false; }
});
$('#password').addEventListener('keydown',event=>{
  if (event.key==='Enter' && !event.isComposing) { event.preventDefault(); if (!loginBusy) $('#login-form').requestSubmit($('#login-button')); }
});

$('#send-form').addEventListener('submit', async event => {
  event.preventDefault(); const button = $('#send-button'); button.disabled = true; notice(t('inspecting'));
  const downloadKey=$('#gigafile-download-key').value;
  $('#gigafile-download-key').value='';
  if (downloadKey.length > 4) { notice(t('invalidDownloadKey')); button.disabled=false; return; }
  try {
    await preferenceSave;
    const result = await send({ type:'submit', url:$('#download-url').value.trim(), password:$('#auto-extract').checked ? $('#archive-password').value : '', downloadKey });
    $('#download-url').value = '';
    $('#archive-password').value = '';
    notice(result.count > 1 ? t('addedMany', { count:result.count }) : t('added', { name:result.file.name }), true);
    await refresh();
  } catch (error) { notice(error.message); } finally { button.disabled = false; }
});

$('#use-tab-button').addEventListener('click', () => useCurrentTab(true));
$('#refresh-button').addEventListener('click', async () => { const button=$('#refresh-button'); button.disabled=true; try { await polling.refresh(); } finally { button.disabled=false; } });
$('#logout-button').addEventListener('click', async () => { polling.stop(); await polling.refresh(); await send({type:'logout'}); render({...uiState,connected:false,token:'',jobs:[]}); notice(''); });
let preferenceSave = Promise.resolve();
$('#auto-extract').addEventListener('change', event => {
  const toggle=event.target, value=toggle.checked, previous=uiState.autoExtract !== false;
  toggle.disabled=true;
  preferenceSave=send({type:'savePreferences',autoExtract:value}).then(()=>{
    uiState.autoExtract=value;
    $('#archive-password').disabled=!value;
    if (!value) $('#archive-password').value='';
  }).catch(error=>{toggle.checked=previous;notice(error.message);}).finally(()=>{toggle.disabled=false;});
  return preferenceSave;
});
$('#close-detail').addEventListener('click',closeJob);
$('#language-select').addEventListener('change',async event=>{
  const select=event.target,previous=uiState.language||'auto';select.disabled=true;
  try {
    await send({type:'savePreferences',language:select.value});
    uiState.language=select.value;language=NASDropI18n.resolve(select.value,navigator.language);
    localize();renderJobs(uiState.jobs);syncJobDetail();notice('');$('#poll-status').textContent='';
  } catch(error) {select.value=previous;notice(error.message);}
  finally {select.disabled=false;}
});
async function runJobAction(type, buttonId, jobId=selectedJob) {
  if (mutationBusy || (buttonId && $(buttonId).disabled)) return;
  const states=type==='jobPause'?pausableStates:type==='jobResume'?resumableStates:deletableStates;
  let job=uiState.jobs.find(job=>job.id===jobId);
  if (!job || !states.has(job.status)) return;
  const wasCompleted=job.status==='completed';
  if (type==='jobDelete' && !confirm(t(job.status==='completed'?'deleteRecordConfirm':'deletePartialConfirm'))) return;
  mutationBusy=true; polling.stop(); renderJobs(uiState.jobs); syncJobDetail();
  try {
    await polling.refresh();
    const latest=await send({type:'getJobs'}); uiState.jobs=latest.jobs;
    job=uiState.jobs.find(job=>job.id===jobId);
    if (!job || !uiState.connected || !states.has(job.status) || (type==='jobDelete' && (job.status==='completed')!==wasCompleted)) return;
    await send({type,id:job.id});
    if (type==='jobDelete') { uiState.jobs=uiState.jobs.filter(item=>item.id!==job.id); if (selectedJob===job.id) closeJob(); }
    else job.status=type==='jobPause'?'stopping':'queued';
    renderJobs(uiState.jobs); notice(t(type==='jobDelete'?'jobDeleted':'jobActionDone'),true);
  } catch(error) { notice(error.message); }
  finally {mutationBusy=false; renderJobs(uiState.jobs); syncJobDetail(); await polling.start();}
}
$('#job-password').addEventListener('input',()=>{optionsDirty=true;});
$('#clear-completed').addEventListener('click',async()=>{
  if (mutationBusy || !confirm(t('clearConfirm'))) return;
  mutationBusy=true; polling.stop(); renderJobs(uiState.jobs);
  try { await polling.refresh(); await send({type:'clearCompleted'}); notice(t('cleared'),true); }
  catch (error) { notice(error.message); }
  finally { mutationBusy=false; await polling.start(); }
});
$('#job-options-form').addEventListener('submit',async event=>{
  event.preventDefault(); if (mutationBusy || $('#save-job-options').disabled) return;
  mutationBusy=true; polling.stop(); syncJobDetail();
  try {
    await polling.refresh();
    const job=uiState.jobs.find(job=>job.id===selectedJob);
    await send({type:uiState.status?.job_processing_options ? 'jobProcessing' : 'jobPassword',id:selectedJob,extract:Boolean(job?.extract),password:$('#job-password').value});
    $('#job-password').value=''; optionsDirty=false; notice(t('saved'),true);
  } catch(error) { notice(error.message); }
  finally {mutationBusy=false; await polling.start(); syncJobDetail();}
});
$('#job-download-key').addEventListener('input',()=>{notice('');});
$('#job-download-key-form').addEventListener('submit',async event=>{
  event.preventDefault(); if (mutationBusy || $('#save-job-download-key').disabled) return;
  const downloadKey=$('#job-download-key').value;
  $('#job-download-key').value='';
  if (downloadKey.length < 1 || downloadKey.length > 4) { notice(t('invalidDownloadKey')); return; }
  mutationBusy=true; polling.stop(); syncJobDetail();
  try {
    await send({type:'jobDownloadKey',id:selectedJob,downloadKey});
    notice(t('jobActionDone'),true);
  } catch(error) { notice(error.message); }
  finally {mutationBusy=false; await polling.start(); syncJobDetail();}
});
document.addEventListener('visibilitychange',()=>polling.visibilityChanged());
window.addEventListener('pagehide',()=>{polling.stop(); popupPort.disconnect(); $('#job-password').value=''; $('#archive-password').value=''; $('#gigafile-download-key').value=''; $('#job-download-key').value='';});

localize();
refresh().then(() => useCurrentTab(false)).catch(error => notice(error.message));