361 lines
20 KiB
JavaScript
361 lines
20 KiB
JavaScript
/* Local question-bank import and storage. No network requests are made. */
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
const ROLES = Object.freeze(['主操', '班长', '副操']);
|
||
const BANK_KEY = 'yianxue.assistant.bank.v2';
|
||
const SETTING_PREFIX = 'yianxue.assistant.setting.v2.';
|
||
const MAX_BYTES = 60 * 1024 * 1024;
|
||
let current = null;
|
||
let importQueue = Promise.resolve();
|
||
|
||
function normalize(value) {
|
||
// Answer identity must retain case, unit spelling, superscripts and punctuation.
|
||
// Only presentation whitespace and invisible layout characters are cleaned.
|
||
return String(value == null ? '' : value)
|
||
.replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
|
||
.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function normalizeType(value) {
|
||
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
|
||
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
|
||
}
|
||
|
||
function optionKey(options) {
|
||
return JSON.stringify((options || []).map(option => normalize(option.text)).sort());
|
||
}
|
||
|
||
function questionKey(record) {
|
||
return JSON.stringify([normalizeType(record.type), normalize(record.question)]);
|
||
}
|
||
|
||
function hash(value) {
|
||
let result = 2166136261;
|
||
for (let i = 0; i < value.length; i++) { result ^= value.charCodeAt(i); result = Math.imul(result, 16777619); }
|
||
return (result >>> 0).toString(36);
|
||
}
|
||
|
||
function storage() {
|
||
// Userscript grants can be sandbox globals without being properties of window.
|
||
const legacyGet = typeof GM_getValue === 'function' ? GM_getValue : root.GM_getValue;
|
||
const legacySet = typeof GM_setValue === 'function' ? GM_setValue : root.GM_setValue;
|
||
const modern = typeof GM !== 'undefined' && GM ? GM : root.GM;
|
||
if (typeof legacyGet === 'function' && typeof legacySet === 'function') {
|
||
return { name: 'userscript', get: (key, fallback) => Promise.resolve(legacyGet(key, fallback)), set: (key, value) => Promise.resolve(legacySet(key, value)) };
|
||
}
|
||
if (modern && typeof modern.getValue === 'function' && typeof modern.setValue === 'function') {
|
||
return { name: 'userscript', get: (key, fallback) => modern.getValue(key, fallback), set: (key, value) => modern.setValue(key, value) };
|
||
}
|
||
return {
|
||
name: 'origin',
|
||
get: async function (key, fallback) {
|
||
try { const raw = root.localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); }
|
||
catch (error) { throw new Error('无法读取此网址的本地存储:' + error.message); }
|
||
},
|
||
set: async function (key, value) {
|
||
try { root.localStorage.setItem(key, JSON.stringify(value)); }
|
||
catch (error) { throw new Error('题库保存失败;请检查浏览器存储空间或油猴权限:' + error.message); }
|
||
}
|
||
};
|
||
}
|
||
|
||
function snapshot(records, revision, storageName) {
|
||
const counts = { 主操: 0, 班长: 0, 副操: 0 };
|
||
records.forEach(record => { counts[record.role]++; });
|
||
return { schemaVersion: 2, records, counts, total: records.length, revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
|
||
}
|
||
|
||
// RFC 4180: quoted commas, escaped quotes, CRLF and embedded line breaks.
|
||
function parseCSV(input) {
|
||
const text = String(input).replace(/^\uFEFF/, '');
|
||
const rows = [];
|
||
let row = [], field = '', quoted = false, closed = false, line = 1;
|
||
function pushField() { row.push(field); field = ''; closed = false; }
|
||
function pushRow() { pushField(); if (row.some(value => value !== '')) rows.push(row); row = []; }
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (quoted) {
|
||
if (ch === '"') {
|
||
if (text[i + 1] === '"') { field += '"'; i++; }
|
||
else { quoted = false; closed = true; }
|
||
} else { field += ch; if (ch === '\n') line++; }
|
||
} else if (ch === ',') { pushField(); }
|
||
else if (ch === '\r' || ch === '\n') {
|
||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||
pushRow(); line++;
|
||
} else if (closed) {
|
||
if (ch !== ' ' && ch !== '\t') throw new Error('CSV 第 ' + line + ' 行的引号后存在非法字符');
|
||
} else if (ch === '"') {
|
||
if (field !== '') throw new Error('CSV 第 ' + line + ' 行有未转义的引号');
|
||
quoted = true;
|
||
} else { field += ch; }
|
||
}
|
||
if (quoted) throw new Error('CSV 引号未闭合(第 ' + line + ' 行附近)');
|
||
if (field !== '' || row.length || closed) pushRow();
|
||
return rows;
|
||
}
|
||
|
||
function restoreCSVCell(value) {
|
||
// toCSV prefixes one apostrophe only for a leading spreadsheet formula marker.
|
||
// A real quote such as '引用, or two leading quotes, is retained.
|
||
return String(value == null ? '' : value).replace(/^'(?=[\s\uFEFF]*[=+\-@])/, '');
|
||
}
|
||
|
||
function roleFromPath(path) {
|
||
return String(path || '').replace(/\\/g, '/').split('/').find(part => ROLES.includes(part)) || '';
|
||
}
|
||
|
||
function courseFromFilename(name) {
|
||
return String(name || '').replace(/\\/g, '/').split('/').pop().replace(/\.(csv|json)$/i, '').replace(/_题库_\d+题(?:_不完整)?$/, '');
|
||
}
|
||
|
||
function boolValue(value) {
|
||
const text = normalize(value).toLowerCase();
|
||
if (/^(正确|对|是|true|√|✓)$/.test(text)) return 'true';
|
||
if (/^(错误|错|否|false|×|✗)$/.test(text)) return 'false';
|
||
return '';
|
||
}
|
||
|
||
function labelsForAnswer(answer, options, type) {
|
||
const raw = String(answer == null ? '' : answer).trim().replace(/^(?:参考答案|正确答案|答案)\s*[::]\s*/, '');
|
||
const compact = raw.toUpperCase().replace(/[\s,,、;;|/]+/g, '');
|
||
if (/^[A-H]+$/.test(compact)) {
|
||
if (new Set(compact).size !== compact.length) throw new Error('参考答案包含重复字母');
|
||
return Array.from(compact);
|
||
}
|
||
const exact = options.filter(option => normalize(option.text) === normalize(raw));
|
||
if (exact.length === 1) return [exact[0].label];
|
||
if (type === '判断' && boolValue(raw)) {
|
||
const found = options.filter(option => boolValue(option.text) === boolValue(raw));
|
||
if (found.length === 1) return [found[0].label];
|
||
}
|
||
throw new Error('参考答案无法对应选项:' + (raw || '答案为空'));
|
||
}
|
||
|
||
function validateRecord(raw, context) {
|
||
context = context || {};
|
||
if (!raw || typeof raw !== 'object') throw new Error('题目记录格式不正确');
|
||
const role = String(raw.role || context.role || '').trim();
|
||
if (!ROLES.includes(role)) throw new Error('题库未标明岗位;请先选择主操、班长或副操再导入');
|
||
if (context.pathRole && raw.role && raw.role !== context.pathRole) throw new Error('题目岗位与 ZIP 目录岗位不一致');
|
||
const type = normalizeType(raw.type);
|
||
if (!['单选', '多选', '判断'].includes(type)) throw new Error('题型不支持或缺失:' + (type || '空'));
|
||
const question = String(raw.question == null ? '' : raw.question).trim();
|
||
if (!question) throw new Error('题干为空');
|
||
if (!Array.isArray(raw.options) || raw.options.length < 2 || raw.options.length > 8) throw new Error('每道题需要 2 至 8 个有效选项');
|
||
const options = raw.options.map((option, index) => {
|
||
if (!option || typeof option !== 'object') throw new Error('选项格式不正确');
|
||
const label = String(option.label || String.fromCharCode(65 + index)).trim().toUpperCase().replace(/[..、::))]$/, '');
|
||
const text = String(option.text == null ? '' : option.text).trim();
|
||
if (!/^[A-H]$/.test(label) || !text) throw new Error('选项字母或文字为空/无效');
|
||
return { label, text, ...(Array.isArray(option.images) && option.images.length ? { images: option.images.map(String) } : {}) };
|
||
});
|
||
if (new Set(options.map(option => option.label)).size !== options.length) throw new Error('选项字母重复');
|
||
let answer = String(raw.answer == null ? '' : raw.answer).trim();
|
||
let labels;
|
||
if (answer) labels = labelsForAnswer(answer, options, type);
|
||
else if (Array.isArray(raw.correctTexts) && raw.correctTexts.length) {
|
||
labels = raw.correctTexts.map(text => {
|
||
const found = options.filter(option => normalize(option.text) === normalize(text));
|
||
if (found.length !== 1) throw new Error('正确答案文字无法唯一对应选项');
|
||
return found[0].label;
|
||
});
|
||
} else throw new Error('参考答案为空');
|
||
if (labels.some(label => !options.some(option => option.label === label))) throw new Error('参考答案引用不存在的选项');
|
||
if ((type === '单选' || type === '判断') && labels.length !== 1) throw new Error('单选/判断题必须只有一个参考答案');
|
||
if (new Set(labels).size !== labels.length) throw new Error('参考答案重复');
|
||
const correctOptions = options.filter(option => labels.includes(option.label));
|
||
const correctTexts = correctOptions.map(option => option.text);
|
||
if (Array.isArray(raw.correctTexts) && raw.correctTexts.length && JSON.stringify(raw.correctTexts.map(normalize).sort()) !== JSON.stringify(correctTexts.map(normalize).sort())) {
|
||
throw new Error('正确答案文字与参考答案字母不一致');
|
||
}
|
||
answer = correctOptions.map(option => option.label).join('');
|
||
const course = String(raw.course || context.course || '').trim();
|
||
if (!course) throw new Error('题库缺少课程名称');
|
||
const sourceUrl = String(raw.sourceUrl || context.sourceUrl || '').trim();
|
||
const number = raw.number == null || raw.number === '' ? context.number || 0 : Number(raw.number);
|
||
if (!Number.isInteger(number) || number < 0) throw new Error('题号格式不正确');
|
||
const record = {
|
||
role, course, sourceUrl, number, type, question, options, correctTexts, answer,
|
||
answerText: correctOptions.map(option => option.label + '. ' + option.text).join(';'),
|
||
explanation: String(raw.explanation == null ? '' : raw.explanation).trim(),
|
||
sourceFile: String(raw.sourceFile || context.sourceFile || ''),
|
||
...(Array.isArray(raw.questionImages) && raw.questionImages.length ? { questionImages: raw.questionImages.map(String) } : {})
|
||
};
|
||
record.questionKey = questionKey(record);
|
||
record.optionKey = optionKey(options);
|
||
record.id = 'qb-' + hash(JSON.stringify([role, course, sourceUrl, number, record.questionKey, record.optionKey, correctTexts.map(normalize).sort()]));
|
||
return record;
|
||
}
|
||
|
||
function readCSV(text, context) {
|
||
const rows = parseCSV(text);
|
||
if (rows.length < 2) throw new Error('CSV 没有题目记录');
|
||
const headers = rows[0].map(value => value.replace(/^\uFEFF/, '').trim());
|
||
if (new Set(headers).size !== headers.length) throw new Error('CSV 列名重复');
|
||
const map = new Map(headers.map((name, index) => [name, index]));
|
||
if (!map.has('题目') || !map.has('参考答案')) throw new Error('CSV 缺少“题目”或“参考答案”列');
|
||
const read = (row, key) => map.has(key) ? restoreCSVCell(row[map.get(key)] || '') : '';
|
||
const optionColumns = headers.map((name, index) => ({ name, index })).filter(item => /^选项[A-H]$/.test(item.name));
|
||
return rows.slice(1).map((row, index) => {
|
||
if (row.length !== headers.length) throw new Error('CSV 第 ' + (index + 2) + ' 条记录的列数与表头不一致');
|
||
let options = optionColumns.map(item => ({ label: item.name.slice(-1), text: restoreCSVCell(row[item.index] || '') })).filter(option => option.text.trim());
|
||
if (!options.length && map.has('全部选项')) {
|
||
options = [];
|
||
read(row, '全部选项').split(/\r?\n/).forEach(line => {
|
||
const match = line.match(/^\s*([A-H])[..、::))]\s*(.*)$/);
|
||
if (match) options.push({ label: match[1], text: match[2] });
|
||
else if (options.length) options[options.length - 1].text += '\n' + line;
|
||
});
|
||
}
|
||
try {
|
||
return validateRecord({
|
||
role: read(row, '岗位') || read(row, '角色') || context.role,
|
||
course: read(row, '题库名称') || read(row, '课程') || context.course,
|
||
sourceUrl: read(row, '来源网址') || context.sourceUrl,
|
||
number: read(row, '题号') || index + 1,
|
||
type: read(row, '题型'), question: read(row, '题目'), options,
|
||
answer: read(row, '参考答案'), explanation: read(row, '题目解析')
|
||
}, context);
|
||
} catch (error) { throw new Error('第 ' + (index + 2) + ' 条记录:' + error.message); }
|
||
});
|
||
}
|
||
|
||
function readJSON(text, context) {
|
||
let data;
|
||
try { data = JSON.parse(String(text).replace(/^\uFEFF/, '')); }
|
||
catch (error) { throw new Error('JSON 格式错误:' + error.message); }
|
||
let rows;
|
||
if (Array.isArray(data)) rows = data;
|
||
else if (data && Array.isArray(data.records)) rows = data.records;
|
||
else if (data && Array.isArray(data.questions)) rows = data.questions;
|
||
else throw new Error('JSON 应包含 questions 或 records 数组');
|
||
if (!rows.length) throw new Error('JSON 没有题目记录');
|
||
const base = { ...context, role: data.role || context.role, course: data.title || data.course || context.course, sourceUrl: data.sourceUrl || context.sourceUrl };
|
||
return rows.map((raw, index) => {
|
||
try { return validateRecord(raw, { ...base, number: index + 1 }); }
|
||
catch (error) { throw new Error('第 ' + (index + 1) + ' 道题:' + error.message); }
|
||
});
|
||
}
|
||
|
||
function isIgnored(name) {
|
||
const parts = String(name).replace(/\\/g, '/').split('/');
|
||
return parts.some(part => /^(?:__MACOSX|\.DS_Store|\.~lock\.|~\$|\._)/i.test(part));
|
||
}
|
||
|
||
function sourceScope(record) {
|
||
return JSON.stringify([record.role, record.course, record.sourceUrl]);
|
||
}
|
||
|
||
function deduplicate(records) {
|
||
const seen = new Set();
|
||
return records.filter(record => {
|
||
const key = JSON.stringify([sourceScope(record), record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
|
||
if (seen.has(key)) return false;
|
||
seen.add(key); return true;
|
||
});
|
||
}
|
||
|
||
async function load() {
|
||
if (current) return current;
|
||
const backend = storage();
|
||
let saved = await backend.get(BANK_KEY, null);
|
||
if (typeof saved === 'string') {
|
||
try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); }
|
||
}
|
||
if (!saved) return (current = snapshot([], 0, backend.name));
|
||
if (!Array.isArray(saved.records)) throw new Error('已缓存题库格式损坏;请重新导入题库');
|
||
const records = saved.records.map((record, index) => {
|
||
try { return validateRecord(record, { number: index + 1 }); }
|
||
catch (error) { throw new Error('已缓存题库的第 ' + (index + 1) + ' 道题无效;请重新导入:' + error.message); }
|
||
});
|
||
current = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
|
||
if (saved.updatedAt) current.updatedAt = saved.updatedAt;
|
||
return current;
|
||
}
|
||
|
||
async function fileBytes(file) {
|
||
if (typeof file.arrayBuffer === 'function') return file.arrayBuffer();
|
||
if (typeof root.FileReader === 'function') return new Promise((resolve, reject) => {
|
||
const reader = new root.FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('无法读取导入文件')); reader.readAsArrayBuffer(file);
|
||
});
|
||
throw new Error('浏览器不支持文件读取');
|
||
}
|
||
|
||
function decodeCSV(bytes) {
|
||
const decoder = root.TextDecoder || (typeof TextDecoder !== 'undefined' ? TextDecoder : null);
|
||
if (!decoder) throw new Error('浏览器不支持 TextDecoder');
|
||
try { return new decoder('utf-8', { fatal: true }).decode(bytes); }
|
||
catch (_) { return new decoder('gb18030', { fatal: true }).decode(bytes); }
|
||
}
|
||
|
||
async function performImport(files, options) {
|
||
const selected = Array.from(files || []).filter(file => !isIgnored(file.webkitRelativePath || file.name));
|
||
if (!selected.length) throw new Error('请选择 ZIP、CSV 或 JSON 题库文件');
|
||
if (options.role && !ROLES.includes(options.role)) throw new Error('岗位只能选择主操、班长或副操');
|
||
const zipFiles = selected.filter(file => /\.zip$/i.test(file.name || ''));
|
||
if (zipFiles.length && (zipFiles.length !== 1 || selected.length !== 1)) throw new Error('ZIP 全量导入时请只选择一个 ZIP 文件');
|
||
const incoming = [];
|
||
let bytesRead = 0;
|
||
function accept(text, name, pathRole) {
|
||
bytesRead += text.length * 2;
|
||
if (bytesRead > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
|
||
const context = { role: pathRole || options.role || '', pathRole, course: courseFromFilename(name), sourceFile: name };
|
||
try {
|
||
incoming.push(...(/\.csv$/i.test(name) ? readCSV(text, context) : readJSON(text, context)));
|
||
} catch (error) { throw new Error(name + ':' + error.message); }
|
||
}
|
||
if (zipFiles.length) {
|
||
if (!root.JSZip || typeof root.JSZip.loadAsync !== 'function') throw new Error('ZIP 解析组件未加载,请重新安装完整脚本');
|
||
const file = zipFiles[0];
|
||
if (file.size > MAX_BYTES) throw new Error('ZIP 文件超过 60 MB');
|
||
const zip = await root.JSZip.loadAsync(await fileBytes(file));
|
||
const entries = Object.values(zip.files).filter(entry => !entry.dir && !isIgnored(entry.name) && /\.(csv|json)$/i.test(entry.name));
|
||
if (!entries.length) throw new Error('ZIP 内没有 CSV 或 JSON 题库');
|
||
if (entries.length > 2000) throw new Error('ZIP 内题库文件数量过多');
|
||
let estimatedSize = 0;
|
||
for (const entry of entries) {
|
||
if (entry._data && entry._data.uncompressedSize) estimatedSize += entry._data.uncompressedSize;
|
||
if (estimatedSize > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
|
||
const text = decodeCSV(await entry.async('uint8array'));
|
||
accept(text, entry.name, roleFromPath(entry.name));
|
||
}
|
||
} else {
|
||
for (const file of selected) {
|
||
const name = file.webkitRelativePath || file.name || '';
|
||
if (!/\.(csv|json)$/i.test(name)) throw new Error('仅支持 ZIP、CSV、JSON 文件:' + name);
|
||
if (file.size > MAX_BYTES) throw new Error('文件超过 60 MB:' + name);
|
||
accept(decodeCSV(await fileBytes(file)), name, roleFromPath(name));
|
||
}
|
||
}
|
||
if (!incoming.length) throw new Error('题库没有有效题目');
|
||
const backend = storage();
|
||
let previous;
|
||
try { previous = await load(); }
|
||
catch (error) { if (!zipFiles.length) throw error; previous = snapshot([], 0, backend.name); }
|
||
const scopes = new Set(incoming.map(sourceScope));
|
||
const retained = zipFiles.length ? [] : previous.records.filter(record => !scopes.has(sourceScope(record)));
|
||
const next = snapshot(deduplicate(retained.concat(incoming)), previous.revision + 1, backend.name);
|
||
// Only publish after every record has passed validation and storage succeeded.
|
||
await backend.set(BANK_KEY, next);
|
||
current = next;
|
||
return next;
|
||
}
|
||
|
||
function importFiles(files, options) {
|
||
const selection = Array.from(files || []);
|
||
const settings = { ...(options || {}) };
|
||
const job = importQueue.then(() => performImport(selection, settings));
|
||
importQueue = job.catch(() => {});
|
||
return job;
|
||
}
|
||
|
||
async function getSetting(key, fallback) { return storage().get(SETTING_PREFIX + String(key), fallback); }
|
||
async function setSetting(key, value) { await storage().set(SETTING_PREFIX + String(key), value); return value; }
|
||
|
||
const api = Object.freeze({ version: '2.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
|
||
root.YianBankStore = api;
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|