Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add import/export functionality #65

Merged
merged 18 commits into from
Apr 14, 2023
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ logs
*.map
index.js
index.d.ts
file.js
file.d.ts
!*/index.js
60 changes: 60 additions & 0 deletions file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const filePickerOptions: FilePickerOptions = {
types: [
{
accept: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'application/json': '.json',
},
},
],
};

const isModern = typeof showOpenFilePicker === 'function';

async function loadFileOld(): Promise<string> {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
const eventPromise = new Promise<Event>(resolve => {
input.addEventListener('change', resolve, {once: true});
});

input.click();
const event = await eventPromise;
const file = (event.target as HTMLInputElement).files![0];
if (!file) {
throw new Error('No file selected');
}

return file.text();
}

async function saveFileOld(text: string, suggestedName: string): Promise<void> {
// Use data URL because Safari doesn't support saving blob URLs
// Use base64 or else linebreaks are lost
const url = `data:application/json;base64,${btoa(text)}`;
const link = document.createElement('a');
link.download = suggestedName;
link.href = url;
link.click();
}

async function loadFileModern(): Promise<string> {
const [fileHandle] = await showOpenFilePicker(filePickerOptions);
const file = await fileHandle.getFile();
return file.text();
}

async function saveFileModern(text: string, suggestedName: string) {
const fileHandle = await showSaveFilePicker({
...filePickerOptions,
suggestedName,
});

const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}

export const loadFile = isModern ? loadFileModern : loadFileOld;
export const saveFile = isModern ? saveFileModern : saveFileOld;
55 changes: 55 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import chromeP from 'webext-polyfill-kinda';
import {isBackground} from 'webext-detect-page';
import {serialize, deserialize} from 'dom-form-serializer/dist/dom-form-serializer.mjs';
import LZString from 'lz-string';
import {loadFile, saveFile} from './file.js';

// eslint-disable-next-line @typescript-eslint/naming-convention -- CJS in ESM imports
const {compressToEncodedURIComponent, decompressFromEncodedURIComponent} = LZString;

function alertAndThrow(message: string): never {
// eslint-disable-next-line no-alert
alert(message);
throw new Error(message);
}

async function shouldRunMigrations(): Promise<boolean> {
const self = await chromeP.management?.getSelf();

Expand Down Expand Up @@ -172,6 +179,9 @@ class OptionsSync<UserOptions extends Options> {
this._form.addEventListener('submit', this._handleFormSubmit);
chrome.storage.onChanged.addListener(this._handleStorageChangeOnForm);
this._updateForm(this._form, await this.getAll());

this._form.querySelector('.js-export')?.addEventListener('click', this.exportToFile);
this._form.querySelector('.js-import')?.addEventListener('click', this.importFromFile);
}

/**
Expand All @@ -181,11 +191,56 @@ class OptionsSync<UserOptions extends Options> {
if (this._form) {
this._form.removeEventListener('input', this._handleFormInput);
this._form.removeEventListener('submit', this._handleFormSubmit);
this._form.querySelector('.js-export')?.addEventListener('click', this.exportToFile);
this._form.querySelector('.js-import')?.addEventListener('click', this.importFromFile);
chrome.storage.onChanged.removeListener(this._handleStorageChangeOnForm);
delete this._form;
}
}

private get _jsonIdentityHelper() {
return '__webextOptionsSync';
}

/**
Opens the browser’s file picker to import options from a previously-saved JSON file
*/
importFromFile = async (): Promise<void> => {
const text = await loadFile();

let options: UserOptions;

try {
options = JSON.parse(text) as UserOptions;
} catch {
alertAndThrow('The file is not a valid JSON file.');
}

if (!(this._jsonIdentityHelper in options)) {
alertAndThrow('The file selected is not a valid recognized options file.');
}

delete options[this._jsonIdentityHelper];

await this.set(options);
if (this._form) {
this._updateForm(this._form, options);
}
};

/**
Opens the browser’s "save file" dialog to export options to a JSON file
*/
exportToFile = async (): Promise<void> => {
const extension = chrome.runtime.getManifest();
const text = JSON.stringify({
[this._jsonIdentityHelper]: extension.name,
...await this.getAll(),
}, null, '\t');

await saveFile(text, extension.name + ' options.json');
};

private _log(method: 'log' | 'info', ...args: any[]): void {
console[method](...args);
}
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webext-options-sync",
"version": "4.0.1",
"version": "4.1.0-5",
"description": "Helps you manage and autosave your extension's options.",
"keywords": [
"browser",
Expand All @@ -19,7 +19,9 @@
"main": "index.js",
"files": [
"index.js",
"index.d.ts"
"index.d.ts",
"file.js",
"file.d.ts"
],
"scripts": {
"build": "tsc",
Expand Down Expand Up @@ -57,6 +59,7 @@
"@types/chrome": "0.0.210",
"@types/lz-string": "^1.3.34",
"@types/throttle-debounce": "^5.0.0",
"@types/wicg-file-system-access": "^2020.9.5",
"ava": "^5.1.1",
"npm-run-all": "^4.1.5",
"sinon": "^15.0.1",
Expand Down
12 changes: 10 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ What storage area type to use (sync storage vs local storage). Sync storage is u

- Sync is default as it's likely more convenient for users.
- Firefox requires [`browser_specific_settings.gecko.id`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings) for the `sync` storage to work locally.
- Sync storage is subject to much tighter [quota limitations](https://developer.chrome.com/docs/extensions/reference/storage/#property-sync), and may cause privacy concerns if the data being stored is confidential.
- Sync storage is subject to much tighter [quota limitations](https://developer.chrome.com/docs/extensions/reference/storage/#property-sync), and may cause privacy concerns if the data being stored is confidential.

#### optionsStorage.set(options)

Expand All @@ -281,7 +281,7 @@ This returns a Promise that will resolve with all the options.

#### optionsStorage.syncForm(form)

Any defaults or saved options will be loaded into the `<form>` and any change will automatically be saved via `chrome.storage.sync`
Any defaults or saved options will be loaded into the `<form>` and any change will automatically be saved via `chrome.storage.sync`. It also looks for any buttons with `js-import` or `js-export` classes that when clicked will allow the user to export and import the options to a JSON file.

##### form

Expand All @@ -293,6 +293,14 @@ It's the `<form>` that needs to be synchronized or a CSS selector (one element).

Removes any listeners added by `syncForm`.

#### optionsStorage.exportFromFile()

Opens the browser’s "save file" dialog to export options to a JSON file. If your form has a `.js-export` element, this listener will be attached automatically.

#### optionsStorage.importFromFile()

Opens the browser’s file picker to import options from a previously-saved JSON file. If your form has a `.js-import` element, this listener will be attached automatically.

## Related

- [webext-options-sync-per-domain](https://github.com/fregante/webext-options-sync-per-domain) - Wrapper for `webext-options-sync` to have different options for each domain your extension supports.
Expand Down