1// [Agent: Codex-GPT-6] [æ¶é´æ³: 2026-09-13 19:20:00 CST] 2const textKey = value => String(value || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase(); 3export const quoteText = record => record?.text || record?.originalText || record?.sentence || record?.content || ''; 4const QUOTE_FIELDS = ['quotes', 'sentences', 'collectedSentences', 'quotesList', 'goldenSentences']; 5 6export function materializeQuotes(asset) { 7 const result = [], seen = new Set(); 8 for (const field of QUOTE_FIELDS) { 9 if (asset[field] !== undefined && !Array.isArray(asset[field])) throw new Error('é奿°æ®æ ¼å¼å¼å¸¸ï¼è¯·å æ£æµæ°æ®å®æ´æ§'); 10 for (const record of asset[field] || []) { 11 if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('éå¥è®°å½æ ¼å¼å¼å¸¸ï¼è¯·å æ£æµæ°æ®å®æ´æ§'); 12 const text = quoteText(record), key = textKey(text) || `id:${record.id ?? record.createdAt ?? ''}`; 13 if (field !== 'quotes' && seen.has(key)) continue; 14 seen.add(key); 15 result.push({ ...record, text, id: String(record.id ?? record.sentenceId ?? record._id ?? record.createdAt ?? crypto.randomUUID()) }); 16 } 17 } 18 return result; 19} 20 21export function parseQuoteLines(text, separator = 'plain') { 22 if (!['plain', 'tab'].includes(separator)) throw new Error('䏿¯æçåéæ ¼å¼'); 23 if (text.length > 200000) throw new Error('æ¯æ¬¡æå¤å¯¼å ¥ 200,000 个å符'); 24 const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter(line => line.trim()); 25 if (lines.length > 500) throw new Error('æ¯æ¬¡æå¤å¯¼å ¥ 500 æ¡éå¥'); 26 const seen = new Set(), quotes = []; 27 let duplicates = 0; 28 for (const line of lines) { 29 const parts = separator === 'tab' ? line.split('\t') : [line]; 30 const original = parts[0].trim(), translation = parts.slice(1).join('\t').trim(); 31 if (!original || original.length > 5000 || translation.length > 5000) throw new Error('åæ¡éå¥åè¯æåé 5,000 åï¼åæä¸è½ä¸ºç©º'); 32 const key = textKey(original); 33 if (seen.has(key)) { duplicates++; continue; } 34 seen.add(key); 35 quotes.push({ text: original, translation }); 36 } 37 return { quotes, duplicates, total: lines.length }; 38} 39 40export function quoteImportPreview(asset, parsed) { 41 const records = QUOTE_FIELDS.flatMap(field => Array.isArray(asset?.[field]) ? asset[field] : []); 42 const existing = new Set(records.map(record => textKey(quoteText(record))).filter(Boolean)); 43 const quotes = parsed.quotes.filter(record => !existing.has(textKey(record.text))); 44 return { ...parsed, quotes, duplicates: parsed.duplicates + parsed.quotes.length - quotes.length }; 45} 46 47export async function importQuotes(parsed, owner) { 48 let counts; 49 const result = await globalThis.HordAssetStore.mutateAsset(ctx => { 50 if (globalThis.HordIdbKv.accountScope() !== owner) throw new Error('è´¦å·å·²ååï¼è¯·éæ°æå¼å¯¼å ¥'); 51 if (ctx.asset.quotes !== undefined && !Array.isArray(ctx.asset.quotes)) throw new Error('é奿°æ®æ ¼å¼å¼å¸¸ï¼è¯·å æ£æµæ°æ®å®æ´æ§'); 52 counts = quoteImportPreview(ctx.asset, parsed); 53 if (!counts.quotes.length) { ctx.abort(); return; } 54 ctx.asset.quotes = materializeQuotes(ctx.asset); 55 const now = Date.now(); 56 ctx.asset.quotes.push(...counts.quotes.map(record => ({ 57 ...record, id: crypto.randomUUID(), createdAt: now, updatedAt: now, 58 sourceLabel: 'ææ¬å¯¼å ¥', isDeleted: false, 59 }))); 60 }); 61 return { result, added: counts.quotes.length, skipped: counts.duplicates }; 62} 63 64export function openQuoteImport(root, onSaved) { 65 if (root.querySelector('#quote-import-dialog')) return; 66 const owner = globalThis.HordIdbKv.accountScope(); 67 const dialog = document.createElement('dialog'); 68 dialog.id = 'quote-import-dialog'; 69 dialog.className = 'hub-tool-dialog'; 70 dialog.setAttribute('aria-labelledby', 'quote-import-title'); 71 dialog.innerHTML = ` 72 <header class="hub-tool-heading"><h2 id="quote-import-title">å¯¼å ¥éå¥</h2><button type="button" class="btn btn-secondary" data-close aria-label="å ³éå¯¼å ¥">å ³é</button></header> 73 <label class="hub-tool-field">ææ¬æ ¼å¼<select id="quote-import-format"><option value="plain">
73æ¯è¡ä¸å¥</option><option value="tab">åæä¸è¯æï¼Tab åéï¼</option></select></label> 74 <label class="hub-tool-field">éå¥åæ<textarea id="quote-import-text" rows="7" maxlength="200000" placeholder="The journey begins with a single step."></textarea></label> 75 <div id="quote-import-preview" class="hub-tool-preview" aria-live="polite"></div> 76 <p id="quote-import-error" role="alert" class="hub-tool-error"></p> 77 <footer class="hub-tool-actions"><a href="https://www.hord.asia/" target="_blank" rel="noopener noreferrer">å®è£ æä»¶ï¼æ¶èç½é¡µéå¥</a><button type="button" class="btn btn-secondary" data-close>åæ¶</button><button type="button" class="btn btn-primary" id="quote-import-confirm" disabled>ç¡®è®¤å¯¼å ¥</button></footer>`; 78 root.append(dialog); 79 const input = dialog.querySelector('textarea'), format = dialog.querySelector('select'); 80 const submit = dialog.querySelector('#quote-import-confirm'), preview = dialog.querySelector('#quote-import-preview'); 81 const error = dialog.querySelector('[role="alert"]'); 82 let parsed = null, epoch = 0, busy = false; 83 const escape = globalThis.HordCore.escapeHtml; 84 const refresh = async () => { 85 const current = ++epoch; 86 submit.disabled = true; parsed = null; error.textContent = ''; 87 try { 88 const candidate = parseQuoteLines(input.value, format.value); 89 const asset = await globalThis.HordIdbKv.get('hord_mobile_asset_v2'); 90 if (current !== epoch || !dialog.isConnected) return; 91 if (owner !== globalThis.HordIdbKv.accountScope()) throw new Error('è´¦å·å·²ååï¼è¯·éæ°æå¼å¯¼å ¥'); 92 const info = quoteImportPreview(asset, candidate); 93 parsed = candidate; 94 preview.innerHTML = `<strong>æ°å¢ ${info.quotes.length} æ¡ Â· è·³è¿éå¤ ${info.duplicates} æ¡</strong><ol>${info.quotes.slice(0, 5).map(q => `<li>${escape(q.text)}${q.translation ? `<small>${escape(q.translation)}</small>` : ''}</li>`).join('')}</ol>`; 95 submit.disabled = !info.quotes.length; 96 } catch (e) { if (current === epoch) { error.textContent = e.message; preview.textContent = ''; } } 97 }; 98 input.addEventListener('input', refresh); format.addEventListener('change', refresh); 99 dialog.querySelectorAll('[data-close]').forEach(button => button.addEventListener('click', () => { if (!busy) dialog.close(); })); 100 dialog.addEventListener('cancel', event => { if (busy) event.preventDefault(); }); 101 dialog.addEventListener('close', () => { epoch++; dialog.remove(); }, { once: true }); 102 submit.addEventListener('click', async () => { 103 if (busy || !parsed) return; 104 busy = true; epoch++; submit.disabled = true; input.disabled = true; format.disabled = true; 105 dialog.querySelectorAll('[data-close]').forEach(button => { button.disabled = true; }); 106 try { 107 const result = await importQuotes(parsed, owner); 108 dialog.close(); 109 if (root.isConnected) await onSaved(result); 110 } catch (e) { error.textContent = `å¯¼å ¥å¤±è´¥ï¼${e.message}`; } 111 finally { 112 busy = false; input.disabled = false; format.disabled = false; submit.disabled = false; 113 dialog.querySelectorAll('[data-close]').forEach(button => { button.disabled = false; }); 114 } 115 }); 116 dialog.showModal(); input.focus(); refresh(); 117}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.