diff --git a/source/background-scripts/initialize.ts b/source/background-scripts/initialize.ts index 0d22280..7791b5f 100644 --- a/source/background-scripts/initialize.ts +++ b/source/background-scripts/initialize.ts @@ -1,6 +1,6 @@ import browser from 'webextension-polyfill'; -import {parseRedirect} from '../redirect/exports.js'; +import storage from '../redirect/storage.js'; async function browserActionClicked() { await browser.runtime.openOptionsPage(); @@ -35,11 +35,8 @@ browser.webNavigation.onBeforeNavigate.addListener(async (details) => { return; } - for (const [id, parameters] of Object.entries( - await browser.storage.local.get(), - )) { - const redirect = parseRedirect(parameters, id); - if (redirect === undefined || !redirect.parameters.enabled) { + for (const redirect of await storage.getRedirects()) { + if (!redirect.parameters.enabled) { continue; } diff --git a/source/options/components/editor.ts b/source/options/components/editor.ts index 4d4286a..223a7c3 100644 --- a/source/options/components/editor.ts +++ b/source/options/components/editor.ts @@ -8,40 +8,26 @@ import { narrowMatcherType, narrowRedirectType, parseRedirect, - Redirects, + Redirect, RedirectParameters, redirectTypes, } from '../../redirect/exports.js'; +import storage from '../../redirect/storage.js'; type Props = { - id: string; - redirect?: Redirects; - removeRedirect: (id: string) => void; - saveRedirect: (redirect: Redirects) => void; + redirect: Redirect; + removeRedirect: (id: number) => void; + saveRedirect: (redirect: Redirect) => void; }; -type State = { - id: string; - redirectValue: string; -} & RedirectParameters; +type State = RedirectParameters; export default class Editor extends Component { - defaultParameters: RedirectParameters; - constructor(props: Props) { super(props); - this.defaultParameters = { - enabled: true, - matcherType: 'hostname', - matcherValue: '', - redirectType: 'simple', - redirectValue: '', - }; - this.state = { - id: this.props.id, - ...this.parametersFromProps(), + ...props.redirect.parameters, }; } @@ -87,21 +73,8 @@ export default class Editor extends Component { this.onSelectChange(event, 'redirect'); }; - parametersFromProps = (): RedirectParameters => { - const redirect = this.props.redirect; - const parameters = redirect?.parameters ?? {...this.defaultParameters}; - - return { - enabled: parameters.enabled, - matcherType: parameters.matcherType, - matcherValue: parameters.matcherValue, - redirectType: parameters.redirectType, - redirectValue: parameters.redirectValue, - }; - }; - - parseRedirect = (): Redirects => { - const redirect = parseRedirect(this.state, this.props.id); + parseRedirect = (): Redirect => { + const redirect = parseRedirect(this.state); if (redirect === undefined) { throw new Error('Failed to parse redirect'); } @@ -109,32 +82,24 @@ export default class Editor extends Component { return redirect; }; - prepareForStorage = ( - parameters: RedirectParameters, - ): Record => { - const storage: Record = {}; - storage[this.props.id] = parameters; - return storage; - }; - remove = async () => { - await browser.storage.local.remove(this.props.id); - this.props.removeRedirect(this.props.id); + const redirect = this.props.redirect; + await browser.storage.local.remove(redirect.idString()); + this.props.removeRedirect(redirect.parameters.id); }; save = async () => { const redirect = this.parseRedirect(); - await browser.storage.local.set( - this.prepareForStorage(redirect.parameters), - ); + await storage.save(redirect); this.props.saveRedirect(redirect); }; toggleEnabled = async () => { const enabled = !this.state.enabled; - const storage = this.prepareForStorage(this.parametersFromProps()); - storage[this.props.id].enabled = enabled; - await browser.storage.local.set(storage); + const redirect = this.props.redirect; + const prepared = await storage.prepareForStorage(redirect); + prepared[redirect.idString()].enabled = enabled; + await browser.storage.local.set(prepared); this.setState({enabled}); }; diff --git a/source/options/components/page-main.ts b/source/options/components/page-main.ts index f63c133..0a2ef4d 100644 --- a/source/options/components/page-main.ts +++ b/source/options/components/page-main.ts @@ -1,13 +1,8 @@ import {html} from 'htm/preact'; import {Component} from 'preact'; -import browser from 'webextension-polyfill'; -import { - parseRedirect, - Redirect, - Redirects, - SimpleRedirect, -} from '../../redirect/exports.js'; +import {Redirect, SimpleRedirect} from '../../redirect/exports.js'; +import storage from '../../redirect/storage.js'; import Editor from './editor.js'; import Usage from './usage.js'; @@ -15,7 +10,7 @@ import Usage from './usage.js'; type Props = Record; type State = { - redirects: Redirects[]; + redirects: Redirect[]; }; export class PageMain extends Component { @@ -28,17 +23,7 @@ export class PageMain extends Component { } async componentDidMount() { - const redirects: Redirects[] = []; - for (const [id, parameters] of Object.entries( - await browser.storage.local.get(), - )) { - const redirect = parseRedirect(parameters, id); - if (redirect === undefined) { - continue; - } - - redirects.push(redirect); - } + const redirects = await storage.getRedirects(); // Sort the redirects by: // * Matcher Type @@ -73,33 +58,36 @@ export class PageMain extends Component { return rValueA.localeCompare(rValueB); }); + this.setState({redirects}); } - addNewRedirect = () => { + addNewRedirect = async () => { + const redirect = new SimpleRedirect({ + enabled: true, + id: await storage.nextRedirectId(), + matcherType: 'hostname', + matcherValue: 'example.com', + redirectType: 'simple', + redirectValue: 'example.org', + }); + await storage.savePrepared(await storage.prepareForStorage(redirect)); this.setState({ - redirects: [ - new SimpleRedirect({ - enabled: true, - matcherType: 'hostname', - matcherValue: 'example.com', - redirectType: 'simple', - redirectValue: 'example.org', - }), - ...this.state.redirects, - ], + redirects: [redirect, ...this.state.redirects], }); }; - removeRedirect = (id: string) => { + removeRedirect = (id: number) => { this.setState({ - redirects: this.state.redirects.filter((redirect) => redirect.id !== id), + redirects: this.state.redirects.filter( + (redirect) => redirect.parameters.id !== id, + ), }); }; - saveRedirect = (redirect: Redirects) => { + saveRedirect = (redirect: Redirect) => { const redirectIndex = this.state.redirects.findIndex( - (found) => found.id === redirect.id, + (found) => found.parameters.id === redirect.parameters.id, ); if (redirectIndex === -1) { this.setState({ @@ -117,8 +105,7 @@ export class PageMain extends Component { (redirect) => html` <${Editor} - key=${redirect.id} - id=${redirect.id} + key=${redirect.idString()} redirect=${redirect} removeRedirect=${this.removeRedirect} saveRedirect=${this.saveRedirect} @@ -126,10 +113,6 @@ export class PageMain extends Component { `, ); - if (editors.length === 0) { - this.addNewRedirect(); - } - return html`
diff --git a/source/options/examples.ts b/source/options/examples.ts index 26ccd52..dbce4b9 100644 --- a/source/options/examples.ts +++ b/source/options/examples.ts @@ -1,8 +1,12 @@ -import {Redirect, RedirectParameters} from '../redirect/base.js'; +import browser from 'webextension-polyfill'; + +import {RedirectParameters} from '../redirect/base.js'; +import storage from '../redirect/storage.js'; const examples: RedirectParameters[] = [ { enabled: true, + id: -1, matcherType: 'hostname', matcherValue: 'twitter.com', redirectType: 'hostname', @@ -10,6 +14,7 @@ const examples: RedirectParameters[] = [ }, { enabled: true, + id: -1, matcherType: 'hostname', matcherValue: 'reddit.com', redirectType: 'hostname', @@ -17,6 +22,7 @@ const examples: RedirectParameters[] = [ }, { enabled: true, + id: -1, matcherType: 'regex', matcherValue: '^https?://holllo\\.org/renav/?$', redirectType: 'simple', @@ -24,12 +30,17 @@ const examples: RedirectParameters[] = [ }, ]; -export function generateExamples(): Record { - const storage: Record = {}; +export async function generateExamples(): Promise< + Record +> { + const prepared: Record = {}; + let nextId = await storage.nextRedirectId(); for (const example of examples) { - const id = Redirect.generateId(); - storage[id] = example; + example.id = nextId; + prepared[`redirect:${nextId}`] = example; + nextId += 1; } - return storage; + await browser.storage.local.set({latestId: nextId - 1}); + return prepared; } diff --git a/source/options/index.ts b/source/options/index.ts index 192595f..e95b9c3 100644 --- a/source/options/index.ts +++ b/source/options/index.ts @@ -1,7 +1,7 @@ import {html} from 'htm/preact'; import {Component, render} from 'preact'; -import browser from 'webextension-polyfill'; +import storage from '../redirect/storage.js'; import {PageFooter} from './components/page-footer.js'; import {PageHeader} from './components/page-header.js'; import {PageMain} from './components/page-main.js'; @@ -10,7 +10,7 @@ import {generateExamples} from './examples.js'; window.addEventListener('DOMContentLoaded', () => { window.Holllo = { async insertExamples() { - await browser.storage.local.set(generateExamples()); + await storage.savePrepared(await generateExamples()); location.reload(); }, }; diff --git a/source/redirect/base.ts b/source/redirect/base.ts index 0c4b6e1..4ac15cf 100644 --- a/source/redirect/base.ts +++ b/source/redirect/base.ts @@ -1,5 +1,3 @@ -import {customAlphabet} from 'nanoid'; - export const matcherTypes = ['hostname', 'regex'] as const; export const redirectTypes = ['hostname', 'simple'] as const; @@ -16,6 +14,7 @@ export function narrowRedirectType(value: string): value is RedirectType { export type RedirectParameters = { enabled: boolean; + id: number; matcherType: MatcherType; matcherValue: string; redirectType: RedirectType; @@ -23,16 +22,14 @@ export type RedirectParameters = { }; export abstract class Redirect { - public static generateId(): string { - const alphabet = 'abcdefghijklmnopqrstuvwxyz'; - const nanoid = customAlphabet(`${alphabet}${alphabet.toUpperCase()}`, 20); - return nanoid(); + public static idString(id: number): string { + return `redirect:${id}`; } - public id: string; + constructor(public parameters: RedirectParameters) {} - constructor(public parameters: RedirectParameters, id?: string) { - this.id = id ?? Redirect.generateId(); + public idString(): string { + return Redirect.idString(this.parameters.id); } public isMatch(url: URL): boolean { diff --git a/source/redirect/exports.ts b/source/redirect/exports.ts index b58f158..e9a4137 100644 --- a/source/redirect/exports.ts +++ b/source/redirect/exports.ts @@ -10,15 +10,14 @@ export type Redirects = HostnameRedirect | SimpleRedirect; export function parseRedirect( parameters: RedirectParameters, - id: string, ): Redirects | undefined { const redirectType = parameters?.redirectType; if (redirectType === 'hostname') { - return new HostnameRedirect(parameters, id); + return new HostnameRedirect(parameters); } if (redirectType === 'simple') { - return new SimpleRedirect(parameters, id); + return new SimpleRedirect(parameters); } } diff --git a/source/redirect/storage.ts b/source/redirect/storage.ts new file mode 100644 index 0000000..8dd0277 --- /dev/null +++ b/source/redirect/storage.ts @@ -0,0 +1,68 @@ +import browser from 'webextension-polyfill'; + +import {Redirect, RedirectParameters} from './base.js'; +import {parseRedirect} from './exports.js'; + +const redirectKeyRegex = /^redirect:\d+$/i; + +async function getRedirects(): Promise { + const redirects: Redirect[] = []; + const stored = await browser.storage.local.get(); + for (const [key, value] of Object.entries(stored)) { + if (!redirectKeyRegex.test(key)) { + continue; + } + + const redirect = parseRedirect(value); + if (redirect !== undefined) { + redirects.push(redirect); + } + } + + return redirects; +} + +async function nextRedirectId(): Promise { + const {latestId} = await browser.storage.local.get('latestId'); + const id = Number(latestId); + + let nextId: number | undefined; + if (Number.isNaN(id)) { + const redirects = await getRedirects(); + nextId = redirects.length + 1; + } else { + nextId = id + 1; + } + + await browser.storage.local.set({latestId: nextId}); + return nextId; +} + +async function prepareForStorage( + redirect: Redirect, +): Promise> { + const prepared: Record = {}; + prepared[redirect.idString()] = redirect.parameters; + return prepared; +} + +async function save(redirect: Redirect): Promise { + await savePrepared(await prepareForStorage(redirect)); +} + +async function savePrepared( + prepared: Record, +): Promise { + await browser.storage.local.set(prepared); +} + +const storage = { + getRedirects, + nextRedirectId, + prepareForStorage, + redirectKeyRegex, + save, + savePrepared, +}; + +export default storage; diff --git a/tests/redirect.test.ts b/tests/redirect.test.ts index 7d10802..dd32ecf 100644 --- a/tests/redirect.test.ts +++ b/tests/redirect.test.ts @@ -17,6 +17,7 @@ import { const hostnameParameters: RedirectParameters = { enabled: true, + id: 1, matcherType: 'hostname', matcherValue: 'example.com', redirectType: 'hostname', @@ -25,6 +26,7 @@ const hostnameParameters: RedirectParameters = { const simpleParameters: RedirectParameters = { enabled: true, + id: 2, matcherType: 'hostname', matcherValue: 'example.com', redirectType: 'simple', @@ -42,13 +44,12 @@ test('parseRedirect', (t) => { ]; for (const sample of samples) { - const redirect = parseRedirect(sample, Redirect.generateId()); + const redirect = parseRedirect(sample); if (redirect === undefined) { t.pass('parseRedirect returned undefined'); } else { - t.regex(redirect.id, /^[a-z]{20}$/i); - redirect.id = 'id'; + t.regex(redirect?.idString(), /^redirect:\d+$/i); t.snapshot(redirect, `Class ${redirect.constructor.name}`); } } @@ -92,6 +93,7 @@ test('Redirect.isMatch', (t) => { const regexMatch = new HostnameRedirect({ enabled: true, + id: 3, matcherType: 'regex', matcherValue: String.raw`^https://(www\.)?example.org/$`, redirectType: 'simple', diff --git a/tests/snapshots/tests/redirect.test.ts.md b/tests/snapshots/tests/redirect.test.ts.md index f278861..87d8eeb 100644 --- a/tests/snapshots/tests/redirect.test.ts.md +++ b/tests/snapshots/tests/redirect.test.ts.md @@ -9,9 +9,9 @@ Generated by [AVA](https://avajs.dev). > Class HostnameRedirect HostnameRedirect { - id: 'id', parameters: { enabled: true, + id: 1, matcherType: 'hostname', matcherValue: 'example.com', redirectType: 'hostname', @@ -22,9 +22,9 @@ Generated by [AVA](https://avajs.dev). > Class SimpleRedirect SimpleRedirect { - id: 'id', parameters: { enabled: true, + id: 2, matcherType: 'hostname', matcherValue: 'example.com', redirectType: 'simple', diff --git a/tests/snapshots/tests/redirect.test.ts.snap b/tests/snapshots/tests/redirect.test.ts.snap index 853aff2..faafc6a 100644 Binary files a/tests/snapshots/tests/redirect.test.ts.snap and b/tests/snapshots/tests/redirect.test.ts.snap differ