PageSourceSearch

https://www.hord.asia/hub/modules/quote-import.js

js hord.asia collected 2026-10-02 14:37:46 UTC 7,461 bytes, 117 lines download raw bytes

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.