diff --git a/.gitignore b/.gitignore index 0908dcb..447f092 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ logs *.map index.js index.d.ts +file.js +file.d.ts !*/index.js diff --git a/file.ts b/file.ts new file mode 100644 index 0000000..d1e0e27 --- /dev/null +++ b/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 { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + const eventPromise = new Promise(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 { + // 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 { + 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; diff --git a/index.ts b/index.ts index 4175665..cf56fb1 100644 --- a/index.ts +++ b/index.ts @@ -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 { const self = await chromeP.management?.getSelf(); @@ -172,6 +179,9 @@ class OptionsSync { 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); } /** @@ -181,11 +191,56 @@ class OptionsSync { 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 => { + 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 => { + 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); } diff --git a/package.json b/package.json index a50b838..1c98276 100644 --- a/package.json +++ b/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", @@ -19,7 +19,9 @@ "main": "index.js", "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "file.js", + "file.d.ts" ], "scripts": { "build": "tsc", @@ -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", diff --git a/readme.md b/readme.md index 4710575..d49b212 100644 --- a/readme.md +++ b/readme.md @@ -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) @@ -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 `
` and any change will automatically be saved via `chrome.storage.sync` +Any defaults or saved options will be loaded into the `` 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 @@ -293,6 +293,14 @@ It's the `` 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.