feat: store the hide-clutter list as a delta, not a snapshot

Saving the editor used to persist the full effective list, which froze the
shipped defaults at that moment: every default added in a later version
never reached anyone who had touched the list once.

Storage now keeps only { removedDefaults, addedDomains }, and the effective
list is resolved against whatever ships in the running version. Entries are
tagged default or user, the editor groups them under comment headers, and
'#' lines are ignored on save. Lists saved by earlier versions are migrated
to the delta form on first read and the legacy key is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-08-14 06:57:41 +02:00
parent b5619ac93e
commit 79a2204a97
21 changed files with 287 additions and 36 deletions
+92 -2
View File
@@ -6,10 +6,17 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BLACKLIST_DOMAINS,
BLACKLIST_OVERRIDES_STORAGE_KEY,
BLACKLIST_SOURCE_DEFAULT,
BLACKLIST_SOURCE_USER,
CUSTOM_BLACKLIST_STORAGE_KEY,
createEmptyBlacklistOverrides,
deriveBlacklistOverrides,
getBlacklistEntries,
getEffectiveBlacklistDomains,
isUrlBlacklisted,
normalizeBlacklistDomain,
normalizeBlacklistOverrides,
parseBlacklistDomains
} from '../shared/blacklist.js';
@@ -34,9 +41,92 @@ assert.equal(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com']),
assert.equal(isUrlBlacklisted('https://notgoogle.com/', ['google.com']), false, 'lookalike domains do not match');
assert.equal(isUrlBlacklisted('not a url', ['example.com']), false, 'invalid URLs are ignored');
// --- Delta storage: shipped defaults keep flowing in after the user edits ---
assert.equal(BLACKLIST_OVERRIDES_STORAGE_KEY, 'blacklistOverrides');
assert.deepEqual(createEmptyBlacklistOverrides(), { removedDefaults: [], addedDomains: [] });
// A user who removes two defaults and adds one of their own.
const edited = BLACKLIST_DOMAINS
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
.concat(['videos.example']);
const overrides = deriveBlacklistOverrides(edited);
assert.deepEqual(overrides.removedDefaults, ['reddit.com', 'imgur.com'], 'only the removed defaults are stored');
assert.deepEqual(overrides.addedDomains, ['videos.example'], 'only the added domains are stored');
const effective = getEffectiveBlacklistDomains(overrides);
assert.equal(effective.includes('reddit.com'), false, 'a removed default stays removed');
assert.equal(effective.includes('videos.example'), true, 'an added domain stays added');
// The property that makes newly shipped defaults reach existing users: every
// shipped domain the user did not explicitly remove is part of the result, so a
// default added in a later version cannot be missing from a stored delta.
const removedSet = new Set(overrides.removedDefaults);
for (const domain of BLACKLIST_DOMAINS) {
assert.equal(
effective.includes(domain) || removedSet.has(domain),
true,
`shipped default ${domain} must be present unless explicitly removed`
);
}
// Legacy full-list snapshots migrate to the delta form.
assert.deepEqual(
deriveBlacklistOverrides(edited),
normalizeBlacklistOverrides(overrides),
'a legacy snapshot produces the same delta'
);
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'no stored delta uses shipped defaults');
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'a legacy empty snapshot still means no filtering');
// Re-adding a removed default clears the removal instead of stacking state.
const readded = deriveBlacklistOverrides(effective.concat(['reddit.com']), overrides);
assert.equal(readded.removedDefaults.includes('reddit.com'), false, 're-adding a default clears its removal');
// A domain the user added explicitly stays tagged as theirs even once the same
// domain ships as a default, so dropping the default does not drop their entry.
const stillUser = deriveBlacklistOverrides(['google.com'], { removedDefaults: [], addedDomains: ['google.com'] });
assert.deepEqual(stillUser.addedDomains, ['google.com'], 'an explicit addition survives becoming a default');
// Contradictory stored state resolves in favour of the addition.
assert.deepEqual(
normalizeBlacklistOverrides({ removedDefaults: ['example.com'], addedDomains: ['example.com'] }),
{ removedDefaults: [], addedDomains: ['example.com'] },
'a domain cannot be removed and added at once'
);
assert.deepEqual(normalizeBlacklistOverrides('nonsense'), createEmptyBlacklistOverrides(), 'garbage storage falls back to defaults');
// Entries are tagged so the editor can show what came from where.
const entries = getBlacklistEntries(overrides);
assert.equal(entries.find(e => e.domain === 'videos.example').source, BLACKLIST_SOURCE_USER);
assert.equal(entries.find(e => e.domain === 'google.com').source, BLACKLIST_SOURCE_DEFAULT);
// Comment lines are editor notes, not domains, and never count as invalid.
const withComments = parseBlacklistDomains('# your entries\nvideos.example\n\n#shipped defaults\ngoogle.com');
assert.deepEqual(withComments.domains, ['videos.example', 'google.com'], 'comment lines are skipped');
assert.deepEqual(withComments.invalid, [], 'comment lines are not reported as invalid');
// Round trip through the grouped editor body: rendering with comment headers
// and saving it again must not change the stored delta.
const rendered = [
'# Your entries',
...entries.filter(e => e.source === BLACKLIST_SOURCE_USER).map(e => e.domain),
'',
'# Shipped defaults',
...entries.filter(e => e.source === BLACKLIST_SOURCE_DEFAULT).map(e => e.domain)
].join('\n');
const roundTripped = parseBlacklistDomains(rendered);
assert.deepEqual(roundTripped.invalid, [], 'the rendered editor body contains no invalid entries');
assert.deepEqual(
deriveBlacklistOverrides(roundTripped.domains, overrides),
normalizeBlacklistOverrides(overrides),
'render then save leaves the delta unchanged'
);
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[CUSTOM_BLACKLIST_STORAGE_KEY\]: domains \}\)/, 'custom list is saved locally');
assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[CUSTOM_BLACKLIST_STORAGE_KEY\]/, 'custom list is never synced');
assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/, 'the delta is saved locally');
assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/, 'the list is never synced');
assert.match(popupSource, /chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/, 'the legacy snapshot is cleaned up after migration');
assert.match(popupSource, /isUrlBlacklisted\(tab\.url, blacklistDomains\)/, 'tab filtering uses the effective custom list');
// A broad parent domain must not hide a host with a dedicated player path,