Skip to content

Commit

Permalink
Add import/export functionality (#65)
Browse files Browse the repository at this point in the history
  • Loading branch information
fregante committed Apr 14, 2023
1 parent e058f9e commit e367be7
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .gitignore
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
@@ -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
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
@@ -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
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

0 comments on commit e367be7

Please sign in to comment.