fix: retain question images and repair evidenced legacy records
This commit is contained in:
+126
-9
@@ -3,6 +3,8 @@
|
||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
|
||||
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
|
||||
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
|
||||
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
|
||||
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||
const storage = {
|
||||
@@ -34,17 +36,98 @@
|
||||
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original });
|
||||
else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
|
||||
}
|
||||
function repairAnswerConflicts(original) {
|
||||
const result = QBCore.repairDuplicateOptionConflicts(original);
|
||||
if (!result.repaired.length) return result;
|
||||
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
|
||||
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
|
||||
throw new Error('答案误报修复备份结构异常,已停止修复');
|
||||
}
|
||||
// Preserve every original version, including later imports and other tabs.
|
||||
// Back up before returning repaired data to any caller that may persist it.
|
||||
const originals = existing ? [...existing.records] : [];
|
||||
const signatures = new Set(originals.map(record => JSON.stringify(record)));
|
||||
for (const item of result.repaired) {
|
||||
const signature = JSON.stringify(item.original);
|
||||
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
|
||||
}
|
||||
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
|
||||
return result;
|
||||
}
|
||||
function imageReplacementSignature(value) {
|
||||
const record = QBCore.normalizeRecord(value);
|
||||
return JSON.stringify([record.key, record.type, record.stem,
|
||||
record.options.map(option => JSON.stringify([option.id, option.text])).sort(),
|
||||
[...record.correctOptionIds].sort(), [...record.answerTexts].sort(), record.answerStatus]);
|
||||
}
|
||||
function imageUpgradeBackup() {
|
||||
const backup = storage.get(IMAGE_UPGRADE_BACKUP_KEY, null);
|
||||
if (backup !== null && (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records) || !Array.isArray(backup.migrations))) {
|
||||
throw new Error('图片补全备份结构异常,已停止覆盖');
|
||||
}
|
||||
return backup;
|
||||
}
|
||||
function ensureImageUpgradeBackup(upgraded) {
|
||||
if (!upgraded.length) return;
|
||||
const previous = imageUpgradeBackup();
|
||||
const stored = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止图片补全');
|
||||
const originals = previous ? [...previous.records] : [], migrations = previous ? [...previous.migrations] : [];
|
||||
const signatures = new Set(originals.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
|
||||
for (const item of upgraded) {
|
||||
const canonical = QBCore.normalizeRecord(item.original);
|
||||
// Compaction may just have assigned occurrence=0 in memory, without a
|
||||
// previous storage write. Capture that exact stored predecessor as well;
|
||||
// only this generated metadata may be absent, never content or answers.
|
||||
const versions = [item.original, ...stored.filter(value => {
|
||||
const record = QBCore.normalizeRecord(value);
|
||||
if (record.key !== canonical.key) return false;
|
||||
if (record.contentOccurrence == null && canonical.contentOccurrence != null) record.contentOccurrence = canonical.contentOccurrence;
|
||||
return JSON.stringify(QBCore.normalizeRecord(record)) === JSON.stringify(canonical);
|
||||
})];
|
||||
for (const value of versions) {
|
||||
const original = QBCore.normalizeRecord(value), originalSignature = JSON.stringify(original);
|
||||
if (!signatures.has(originalSignature)) { signatures.add(originalSignature); originals.push(value); }
|
||||
const migration = { originalKey: original.key, originalSignature, replacementKey: item.replacement.key,
|
||||
replacementSignature: imageReplacementSignature(item.replacement), bankId: item.bankId,
|
||||
batchId: item.batchId, expectedCount: item.expectedCount, questionIndex: item.questionIndex };
|
||||
if (!migrations.some(value => JSON.stringify(value) === JSON.stringify(migration))) migrations.push(migration);
|
||||
}
|
||||
}
|
||||
storage.set(IMAGE_UPGRADE_BACKUP_KEY, { schemaVersion: 1, savedAt: previous?.savedAt || new Date().toISOString(), records: originals, migrations });
|
||||
}
|
||||
function removeBackedUpImagePredecessors(original, replacements) {
|
||||
const backup = imageUpgradeBackup();
|
||||
if (!backup) return original;
|
||||
// A stale tab can reintroduce the exact pre-upgrade record while saving.
|
||||
// Remove only a durably backed-up version with its unchanged, confirmed
|
||||
// replacement present. Different answers or newly changed old data survive.
|
||||
const backups = new Set(backup.records.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
|
||||
return original.filter(value => {
|
||||
const record = QBCore.normalizeRecord(value), signature = JSON.stringify(record);
|
||||
return !backup.migrations.some(item => item.originalKey === record.key && item.originalSignature === signature && backups.has(signature) &&
|
||||
replacements.some(replacement => replacement.answerStatus === 'confirmed' && replacement.key === item.replacementKey &&
|
||||
replacement.questionIdAliases?.includes(record.questionId) && imageReplacementSignature(replacement) === item.replacementSignature));
|
||||
});
|
||||
}
|
||||
try {
|
||||
const saved = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert([], saved));
|
||||
const retained = removeBackedUpImagePredecessors(saved, saved);
|
||||
const repaired = repairAnswerConflicts(retained);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
|
||||
records = compacted.records;
|
||||
const removed = saved.length - records.length;
|
||||
const removed = retained.length - records.length;
|
||||
if (retained.length < saved.length) recordsDirty = true;
|
||||
if (removed > 0) {
|
||||
ensureCleanupBackup(saved);
|
||||
recordsDirty = true;
|
||||
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
|
||||
}
|
||||
if (repaired.repaired.length) {
|
||||
recordsDirty = true;
|
||||
cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`;
|
||||
}
|
||||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||||
let root, shadow, panel, launcher, resultBox, statusBox;
|
||||
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
|
||||
@@ -102,9 +185,10 @@
|
||||
// Merge other tabs before writing; GM change events also refresh an open panel.
|
||||
const stored = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(stored, records));
|
||||
const retained = removeBackedUpImagePredecessors(stored, records);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = compacted.records;
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
storage.set(RECORDS_KEY, records);
|
||||
recordsDirty = false;
|
||||
storageError = false;
|
||||
@@ -113,9 +197,18 @@
|
||||
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
|
||||
}
|
||||
}
|
||||
function receive(incoming) {
|
||||
function receive(incoming, context = {}) {
|
||||
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
|
||||
try {
|
||||
incoming = repairAnswerConflicts(incoming).records;
|
||||
const bank = currentBank();
|
||||
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, {
|
||||
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
|
||||
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
|
||||
});
|
||||
ensureImageUpgradeBackup(imageUpgrade.upgraded);
|
||||
records = imageUpgrade.existing;
|
||||
incoming = imageUpgrade.incoming;
|
||||
const namesById = new Map();
|
||||
incoming.forEach(record => {
|
||||
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
|
||||
@@ -131,7 +224,7 @@
|
||||
const expanded = QBCore.upsert(records, incoming);
|
||||
const compacted = QBCore.compactRecords(expanded);
|
||||
if (compacted.removed > 0) ensureCleanupBackup(expanded);
|
||||
records = compacted.records;
|
||||
records = repairAnswerConflicts(compacted.records).records;
|
||||
recordsDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(persistRecords, 250);
|
||||
@@ -387,6 +480,18 @@
|
||||
download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
|
||||
tell(`已导出整理前的 ${backup.records.length} 条原始记录。`);
|
||||
}
|
||||
function exportOptionRepairBackup() {
|
||||
const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
|
||||
if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。');
|
||||
download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
|
||||
tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`);
|
||||
}
|
||||
function exportImageUpgradeBackup() {
|
||||
const backup = imageUpgradeBackup();
|
||||
if (!backup) return tell('没有图片补全前的备份。');
|
||||
download(JSON.stringify(backup, null, 2), exportFilename('图片补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
|
||||
tell(`已导出图片补全前的 ${backup.records.length} 条原始记录。`);
|
||||
}
|
||||
async function parseImportFile(file) {
|
||||
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
|
||||
const text = (await file.text()).replace(/^\uFEFF/, '');
|
||||
@@ -419,10 +524,10 @@
|
||||
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
|
||||
}
|
||||
const before = records.length;
|
||||
const expanded = QBCore.upsert(records, incoming);
|
||||
const expanded = QBCore.upsert(records, repairAnswerConflicts(removeBackedUpImagePredecessors(incoming, records)).records);
|
||||
const compacted = QBCore.compactRecords(expanded);
|
||||
if (compacted.removed > 0) ensureCleanupBackup(expanded);
|
||||
const merged = compacted.records;
|
||||
const merged = repairAnswerConflicts(compacted.records).records;
|
||||
records = merged;
|
||||
recordsDirty = true;
|
||||
persistRecords();
|
||||
@@ -475,6 +580,8 @@
|
||||
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
||||
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
||||
<div class="section"><h3>重复记录整理</h3><p>重复加载的题目按批次对应合并,保留原题库中的同文题和全部来源 ID。首次整理前会保留原始备份。</p><button type="button" class="btn small" data-action="export-dedup-backup">导出整理前备份</button></div>
|
||||
<div class="section"><h3>答案误报恢复</h3><p>只恢复旧版因多个正确选项文字相同产生、且全部冲突证据一致的误报。修复前记录独立备份,真实答案冲突继续保留。</p><button type="button" class="btn small" data-action="export-option-repair-backup">导出误报修复前备份</button></div>
|
||||
<div class="section"><h3>题目图片补全</h3><p>新完整题库批次与页面题数一致、位置和文字答案均能唯一核对时,补全旧版遗漏的图片,并保留原始题目 ID 与独立备份。</p><button type="button" class="btn small" data-action="export-image-upgrade-backup">导出图片补全前备份</button></div>
|
||||
</div>
|
||||
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
|
||||
</div>
|
||||
@@ -513,6 +620,8 @@
|
||||
case 'export-csv': exportData('csv'); break;
|
||||
case 'export-json': exportData('json'); break;
|
||||
case 'export-dedup-backup': exportCleanupBackup(); break;
|
||||
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
|
||||
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
|
||||
case 'import': shadow.getElementById('qb-import-file').click(); break;
|
||||
}
|
||||
} catch (error) { tell(`操作失败:${error.message}`, true); }
|
||||
@@ -571,10 +680,18 @@
|
||||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
|
||||
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
|
||||
GM_registerMenuCommand('导出图片补全前原始备份', exportImageUpgradeBackup);
|
||||
}
|
||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||
if (!remote || !Array.isArray(value)) return;
|
||||
try { records = QBCore.compactRecords(QBCore.upsert(records, value)).records; if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
|
||||
try {
|
||||
const retained = removeBackedUpImagePredecessors(value, records);
|
||||
const incoming = repairAnswerConflicts(retained);
|
||||
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records;
|
||||
if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); }
|
||||
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
|
||||
}
|
||||
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
|
||||
});
|
||||
if (config.collecting) adapter.start();
|
||||
|
||||
Reference in New Issue
Block a user