Skip to content

Commit 704192a

Browse files
committed
feat: add recoverable import previews
1 parent 85ee40c commit 704192a

4 files changed

Lines changed: 198 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ All notable changes to ScriptHunt will be documented in this file.
1515
- Reconciled package and lockfile version metadata, pinned the verified Playwright release, and expanded version drift tests to cover both lockfile version fields and the dependency pin.
1616
- Replaced implied trust for unscanned/unavailable evidence with explicit Unknown, stale-evidence, and dependency-unverified labels, with inspectable scan URL, timestamp, status, hash, and cache age.
1717
- Added optional bounded-concurrency, non-executing HTTPS integrity checks for `@require` and `@resource` dependencies, including canonical provenance, pinning state, declared hash verification, failures, content changes, and parent-scan caching.
18+
- Added installed/favorite import previews with valid, invalid, duplicate, conflict, and manager-format counts; merge, replace, skip-conflict, and cancel actions; and versioned pre-import snapshots with one-click rollback.
1819

1920
## [v0.5.1]
2021

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ npm run qa
5656
| **Dependency Integrity** | Optional, non-executing HTTPS checks for up to 12 `@require`/`@resource` URLs with 1 MB/7 second limits, pinned/floating provenance, declared hash verification, change detection, and cached evidence |
5757
| **Permission Risk Pills** | Color-coded pills showing @grant danger levels (safe/warn/danger) per script |
5858
| **Script Comparison** | Select up to 3 scripts for side-by-side comparison with best-value highlighting |
59-
| **Favorites** | Save scripts to localStorage with versioned JSON export/import and undo on removal |
59+
| **Favorites** | Save scripts locally with versioned JSON export/import, conflict preview, recovery snapshots, and undo |
6060
| **Saved Searches** | Save query/filter/source combinations locally and manually refresh them to badge new or updated results |
61-
| **Installed Import/Export** | Import and export installed-script lists locally to mark installed scripts and available updates in search results |
61+
| **Installed Import/Export** | Preview manager/app backups before merge, replace, or conflict-skip; every write creates a versioned local snapshot with one-click rollback |
6262
| **Advanced Query Syntax** | `site:`, `author:`, `updated:`, `grant:` operators with domain-aware by-site.json search and metadata-backed grant filtering |
6363
| **Advanced Filters** | Visible controls for source, license, installs, updated date, catalog language, @grant, risk, and applies-to domain |
6464
| **Applies-To Evidence** | Site-filtered results show source site matches alongside parsed `@match`, `@include`, and `@exclude` metadata evidence |
@@ -234,7 +234,7 @@ Yes. Deploy the included Cloudflare Worker template (free tier: 100K requests/da
234234
- **CORS Proxy** — optional custom route followed by an opt-out public allorigins.win → codetabs → everyorigin fallback chain with explicit target-URL disclosure
235235
- **PWA** — manifest.json + service worker for installability, explicit update prompts, offline shell fallback notices, and local recent-search recovery
236236
- **localStorage + IndexedDB** - preferences, favorites, source toggles, theme, recent search results, and scan cache persist locally; diagnostics can clear recoverable caches without deleting user records; no tracking, no cookies, no server-side state
237-
- **Versioned JSON payloads** - favorites and installed-list exports use schema v1; imports validate URLs, report skipped invalid rows, and still accept manager-style `scripts` arrays plus legacy arrays
237+
- **Versioned JSON payloads** - favorites and installed-list exports use schema v1; imports validate URLs, preview invalid/duplicate/conflict counts and manager provenance, then snapshot both local lists before mutation for rollback
238238
- **Local QA** - `npm run qa` runs npm audit, Worker tests, version/source-documentation drift checks, and browser tests against the repo-local static server; Playwright is pinned to the exact locally verified release
239239

240240
---

index.html

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,116 @@ <h3>Search userscripts everywhere</h3>
12481248
return { type: type, items: items, invalid: invalid, managerFormat: managerFormat };
12491249
}
12501250

1251+
function importRecordsEqual(a, b) {
1252+
return JSON.stringify(exportScriptRecord(a)) === JSON.stringify(exportScriptRecord(b));
1253+
}
1254+
1255+
function buildImportPreview(parsed) {
1256+
var existing = parsed.type === 'installed' ? getInstalledScripts() : getFavs();
1257+
var existingByKey = {};
1258+
existing.forEach(function(item) { existingByKey[scriptRecordKey(item)] = item; });
1259+
var incomingByKey = {}, duplicateRows = 0;
1260+
parsed.items.forEach(function(item) {
1261+
var key = scriptRecordKey(item);
1262+
if (incomingByKey[key]) duplicateRows++;
1263+
incomingByKey[key] = item;
1264+
});
1265+
var incoming = Object.keys(incomingByKey).map(function(key) { return incomingByKey[key]; });
1266+
var conflicts = 0, duplicates = duplicateRows, additions = 0;
1267+
incoming.forEach(function(item) {
1268+
var current = existingByKey[scriptRecordKey(item)];
1269+
if (!current) additions++;
1270+
else if (importRecordsEqual(current, item)) duplicates++;
1271+
else conflicts++;
1272+
});
1273+
return {
1274+
type: parsed.type,
1275+
items: incoming,
1276+
existing: existing,
1277+
valid: parsed.items.length,
1278+
invalid: parsed.invalid,
1279+
duplicates: duplicates,
1280+
conflicts: conflicts,
1281+
additions: additions,
1282+
managerFormat: parsed.managerFormat,
1283+
};
1284+
}
1285+
1286+
function saveImportSnapshot(type) {
1287+
var snapshot = {
1288+
schema: 'scripthunt-import-snapshot',
1289+
version: 1,
1290+
createdAt: new Date().toISOString(),
1291+
importType: type,
1292+
favorites: getFavs(),
1293+
installed: getInstalledScripts(),
1294+
};
1295+
localStorage.setItem('sh_import_snapshot', JSON.stringify(snapshot));
1296+
return snapshot;
1297+
}
1298+
1299+
function restoreImportSnapshot(snapshot) {
1300+
if (!snapshot || snapshot.schema !== 'scripthunt-import-snapshot' || snapshot.version !== 1) return false;
1301+
setFavs(snapshot.favorites || []);
1302+
setInstalledScripts(snapshot.installed || []);
1303+
if (state.showFavs) showFavorites();
1304+
else if (state.results.length) sortAndRender();
1305+
return true;
1306+
}
1307+
1308+
function applyImportPreview(preview, mode) {
1309+
var snapshot = saveImportSnapshot(preview.type);
1310+
var existingByKey = {};
1311+
preview.existing.forEach(function(item) { existingByKey[scriptRecordKey(item)] = item; });
1312+
var next;
1313+
if (mode === 'replace') {
1314+
next = preview.items.slice();
1315+
} else {
1316+
next = preview.existing.slice();
1317+
preview.items.forEach(function(item) {
1318+
var key = scriptRecordKey(item);
1319+
var index = next.findIndex(function(current) { return scriptRecordKey(current) === key; });
1320+
if (index < 0) next.push(item);
1321+
else if (mode === 'merge') next[index] = item;
1322+
});
1323+
}
1324+
if (preview.type === 'installed') {
1325+
setInstalledScripts(next);
1326+
if (state.results.length) sortAndRender();
1327+
} else {
1328+
setFavs(next);
1329+
if (state.showFavs) showFavorites();
1330+
}
1331+
closeCompareModal();
1332+
var manager = preview.managerFormat ? '; manager backup detected' : '';
1333+
showToast('Imported ' + preview.items.length + ' ' + preview.type + ' records using ' + mode + manager, 'info', 8000, function() {
1334+
if (restoreImportSnapshot(snapshot)) showToast('Import rolled back', 'info', 2500);
1335+
});
1336+
}
1337+
1338+
function openImportPreview(preview) {
1339+
modalReturnFocus = document.activeElement;
1340+
var overlay = document.getElementById('modalOverlay');
1341+
var modal = document.getElementById('modalContent');
1342+
overlay.setAttribute('aria-label', 'Import preview');
1343+
modal.innerHTML = '<div class="modal-header"><h2 class="modal-title">Import Preview</h2><button class="modal-close" id="modalCloseBtn" aria-label="Cancel import">&times;</button></div>' +
1344+
'<div class="compare-grid" style="grid-template-columns:repeat(5,minmax(0,1fr))">' +
1345+
'<div class="compare-col"><h3>Valid</h3><div class="compare-value">' + preview.valid + '</div></div>' +
1346+
'<div class="compare-col"><h3>Invalid</h3><div class="compare-value">' + preview.invalid + '</div></div>' +
1347+
'<div class="compare-col"><h3>Duplicates</h3><div class="compare-value">' + preview.duplicates + '</div></div>' +
1348+
'<div class="compare-col"><h3>Conflicts</h3><div class="compare-value">' + preview.conflicts + '</div></div>' +
1349+
'<div class="compare-col"><h3>New</h3><div class="compare-value">' + preview.additions + '</div></div></div>' +
1350+
'<p class="bookmarklet-desc">Type: ' + esc(preview.type) + (preview.managerFormat ? '; manager backup: ' + esc(preview.managerFormat) : '') + '. Merge updates conflicts, replace discards the current list, and skip keeps current conflicting records.</p>' +
1351+
'<div class="diagnostics-actions"><button class="tool-btn" id="importCancel" type="button">Cancel</button><button class="tool-btn" id="importSkip" type="button">Skip conflicts</button><button class="tool-btn" id="importReplace" type="button">Replace</button><button class="tool-btn" id="importMerge" type="button">Merge</button></div>';
1352+
overlay.classList.add('visible');
1353+
document.getElementById('modalCloseBtn').addEventListener('click', closeCompareModal);
1354+
document.getElementById('importCancel').addEventListener('click', closeCompareModal);
1355+
document.getElementById('importSkip').addEventListener('click', function() { applyImportPreview(preview, 'skip'); });
1356+
document.getElementById('importReplace').addEventListener('click', function() { applyImportPreview(preview, 'replace'); });
1357+
document.getElementById('importMerge').addEventListener('click', function() { applyImportPreview(preview, 'merge'); });
1358+
document.getElementById('importMerge').focus();
1359+
}
1360+
12511361
function renderProvenance(item, installed) {
12521362
if (!installed || !installed.installed || !installed.record) return '';
12531363
var rec = installed.record;
@@ -3098,6 +3208,7 @@ <h3>Search userscripts everywhere</h3>
30983208
function closeCompareModal() {
30993209
var overlay = document.getElementById('modalOverlay');
31003210
overlay.classList.remove('visible');
3211+
overlay.setAttribute('aria-label', 'Script comparison detail');
31013212
if (modalReturnFocus && document.contains(modalReturnFocus)) modalReturnFocus.focus();
31023213
modalReturnFocus = null;
31033214
}
@@ -3116,6 +3227,7 @@ <h3>Search userscripts everywhere</h3>
31163227
var items = state.compareIds.map(function(id) { return state.results.find(function(r) { return r.id === id; }); }).filter(Boolean);
31173228
if (items.length < 2) return;
31183229
modalReturnFocus = document.activeElement;
3230+
document.getElementById('modalOverlay').setAttribute('aria-label', 'Script comparison detail');
31193231
var modal = document.getElementById('modalContent');
31203232
var colW = items.length === 2 ? '1fr 1fr' : '1fr 1fr 1fr';
31213233
var h = '<div class="modal-header"><h2 class="modal-title">Script Comparison</h2><button class="modal-close" id="modalCloseBtn" aria-label="Close comparison">&times;</button></div>';
@@ -3998,22 +4110,7 @@ <h3>Search userscripts everywhere</h3>
39984110
reader.onload = function(e) {
39994111
try {
40004112
var parsed = parseScriptImportPayload(JSON.parse(e.target.result), importMode);
4001-
if (parsed.type === 'installed') {
4002-
setInstalledScripts(parsed.items);
4003-
if (state.results.length) sortAndRender();
4004-
var importMsg = 'Imported ' + parsed.items.length + ' installed scripts' + (parsed.invalid ? ' (' + parsed.invalid + ' invalid skipped)' : '');
4005-
if (parsed.managerFormat) importMsg += ' (detected manager backup)';
4006-
showToast(importMsg, 'info', 3000);
4007-
return;
4008-
}
4009-
var existing = getFavs(), ids = new Set(existing.map(scriptRecordKey));
4010-
var added = 0;
4011-
parsed.items.forEach(function(item) {
4012-
var key = scriptRecordKey(item);
4013-
if (key && !ids.has(key)) { existing.push(item); ids.add(key); added++; }
4014-
});
4015-
setFavs(existing); showFavorites();
4016-
showToast('Imported ' + added + ' new favorites (' + (parsed.items.length - added) + ' duplicates skipped' + (parsed.invalid ? ', ' + parsed.invalid + ' invalid skipped' : '') + ')', 'info', 3000);
4113+
openImportPreview(buildImportPreview(parsed));
40174114
} catch(err) { showToast('Invalid import file', 'error', 3000); }
40184115
};
40194116
reader.readAsText(file); this.value = '';

tests/smoke.spec.js

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ test('installed import marks matching scripts and update state', async ({ page }
315315
}],
316316
})),
317317
});
318-
await expect(page.locator('.toast').last()).toContainText('Imported 1 installed scripts');
318+
await expect(page.getByRole('dialog', { name: 'Import preview' })).toContainText('Valid');
319+
await page.click('#importReplace');
320+
await expect(page.locator('.toast').last()).toContainText('Imported 1 installed records using replace');
319321

320322
await runSearch(page);
321323
const card = page.locator('.result-card').filter({ hasText: 'YouTube Enhancer' });
@@ -359,7 +361,11 @@ test('script imports skip invalid rows without aborting valid rows', async ({ pa
359361
})),
360362
});
361363

362-
await expect(page.locator('.toast').last()).toContainText('Imported 1 installed scripts (2 invalid skipped)');
364+
const preview = page.getByRole('dialog', { name: 'Import preview' });
365+
await expect(preview).toContainText('Valid');
366+
await expect(preview).toContainText('Invalid');
367+
await page.click('#importReplace');
368+
await expect(page.locator('.toast').last()).toContainText('Imported 1 installed records using replace');
363369
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sh_installed_scripts')));
364370
expect(stored.schema).toBe('scripthunt-installed');
365371
expect(stored.version).toBe(1);
@@ -396,15 +402,86 @@ test('manager-style backup imports are recognized and normalized', async ({ page
396402
])),
397403
});
398404

399-
await expect(page.locator('.toast').last()).toContainText('Imported 2 installed scripts');
400-
await expect(page.locator('.toast').last()).toContainText('manager backup');
405+
await expect(page.getByRole('dialog', { name: 'Import preview' })).toContainText('manager backup: manager-array');
406+
await page.click('#importReplace');
407+
await expect(page.locator('.toast').last()).toContainText('Imported 2 installed records using replace');
408+
await expect(page.locator('.toast').last()).toContainText('manager backup detected');
401409

402410
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sh_installed_scripts')));
403411
expect(stored.installed).toHaveLength(2);
404412
expect(stored.installed[0].name).toBe('VM Exported Script');
405413
expect(stored.installed[0].installUrl).toBe('https://greasyfork.org/scripts/999/code.user.js');
406414
});
407415

416+
test('import preview supports cancellation, conflict merge, snapshots, and rollback', async ({ page }) => {
417+
await page.goto('/');
418+
await page.evaluate(() => {
419+
setInstalledScripts([{
420+
name: 'Existing Script',
421+
version: '1.0.0',
422+
installUrl: 'https://example.com/existing.user.js',
423+
}]);
424+
});
425+
const payload = {
426+
schema: 'scripthunt-installed',
427+
version: 1,
428+
installed: [
429+
{ name: 'Existing Script', version: '2.0.0', installUrl: 'https://example.com/existing.user.js' },
430+
{ name: 'New Script', version: '1.0.0', installUrl: 'https://example.com/new.user.js' },
431+
{ name: 'New Script', version: '1.0.0', installUrl: 'https://example.com/new.user.js' },
432+
{ name: 'Invalid Script', installUrl: 'javascript:alert(1)' },
433+
],
434+
};
435+
const chooseImport = async () => {
436+
const chooserPromise = page.waitForEvent('filechooser');
437+
await page.click('#btnImportInstalled');
438+
const chooser = await chooserPromise;
439+
await chooser.setFiles({
440+
name: 'preview.json',
441+
mimeType: 'application/json',
442+
buffer: Buffer.from(JSON.stringify(payload)),
443+
});
444+
};
445+
446+
await chooseImport();
447+
const preview = page.getByRole('dialog', { name: 'Import preview' });
448+
const counts = preview.locator('.compare-col');
449+
await expect(counts.nth(0)).toContainText('Valid3');
450+
await expect(counts.nth(1)).toContainText('Invalid1');
451+
await expect(counts.nth(2)).toContainText('Duplicates1');
452+
await expect(counts.nth(3)).toContainText('Conflicts1');
453+
await expect(counts.nth(4)).toContainText('New1');
454+
await page.click('#importCancel');
455+
expect(await page.evaluate(() => getInstalledScripts().map((item) => item.version))).toEqual(['1.0.0']);
456+
expect(await page.evaluate(() => localStorage.getItem('sh_import_snapshot'))).toBeNull();
457+
458+
await chooseImport();
459+
await page.click('#importMerge');
460+
const merged = await page.evaluate(() => getInstalledScripts().map((item) => ({ name: item.name, version: item.version })));
461+
expect(merged).toEqual([
462+
{ name: 'Existing Script', version: '2.0.0' },
463+
{ name: 'New Script', version: '1.0.0' },
464+
]);
465+
const snapshot = await page.evaluate(() => JSON.parse(localStorage.getItem('sh_import_snapshot')));
466+
expect(snapshot).toMatchObject({
467+
schema: 'scripthunt-import-snapshot',
468+
version: 1,
469+
importType: 'installed',
470+
});
471+
472+
await page.locator('.toast').last().getByRole('button', { name: 'Undo' }).click();
473+
const restored = await page.evaluate(() => getInstalledScripts());
474+
expect(restored).toHaveLength(1);
475+
expect(restored[0]).toMatchObject({ name: 'Existing Script', version: '1.0.0' });
476+
477+
const downloadPromise = page.waitForEvent('download');
478+
await page.click('#btnExportInstalled');
479+
const download = await downloadPromise;
480+
const exported = JSON.parse(await fs.readFile(await download.path(), 'utf8'));
481+
expect(exported.installed).toHaveLength(1);
482+
expect(exported.installed[0]).toMatchObject({ name: 'Existing Script', version: '1.0.0' });
483+
});
484+
408485
test('result icon buttons have accessible names', async ({ page }) => {
409486
await page.goto('/');
410487
await runSearch(page);

0 commit comments

Comments
 (0)