diff --git a/addon/src/js/sync/cloud/cloud.js b/addon/src/js/sync/cloud/cloud.js index d5767a4f..12b4f96d 100644 --- a/addon/src/js/sync/cloud/cloud.js +++ b/addon/src/js/sync/cloud/cloud.js @@ -18,9 +18,14 @@ import backgroundSelf from '/js/background.js'; const logger = new Logger('Cloud'); export function CloudError(langId) { - console.error(langId) + console.error('CloudError:', langId) this.id = langId; this.message = browser.i18n.getMessage(langId); + + if (!this.message) { + this.message = langId; + } + this.toString = () => 'CloudError: ' + this.message; } @@ -44,25 +49,29 @@ export async function sync() { const GithubGistCloud = new GithubGist(syncOptions.githubGistToken, syncOptions.githubGistFileName, syncOptions.githubGistId); - try { - await GithubGistCloud.checkToken(); - } catch (e) { - throw new CloudError('invalidGithubToken'); - } + // try { + // await GithubGistCloud.checkToken(); + // } catch (e) { + // throw new CloudError(e.message); + // } // throw error only on invalid json content async function getGistData() { - const gist = await GithubGistCloud.getGist().catch(() => {}); + try { + const gist = await GithubGistCloud.getGist(); - if (gist) { try { return JSON.parse(gist.content); } catch (e) { - throw new CloudError('invalidGithubGistContent'); + throw Error('githubInvalidGistContent'); + } + } catch ({message}) { + if (message === 'githubNotFound') { + return null; } - } - return null; + throw new CloudError(message); + } } let cloudData = await getGistData(), @@ -89,10 +98,6 @@ export async function sync() { const localData = await Promise.all([Storage.get(), Groups.load(null, true)]) .then(([data, {groups}]) => { data.groups = groups; - // data.groups = groups.map(group => { - // group.tabs = Tabs.prepareForSave(group.tabs, false, data.syncTabFavIcons); - // return group; - // }); data.containers = Containers.getToExport(data); return data; }); @@ -138,45 +143,65 @@ export async function sync() { await Tabs.remove(Array.from(syncResult.changes.tabsToRemove)); } - // const allTabIds = syncResult.localData.groups.reduce((acc, group) => { - // if (group.isArchive) { - // return acc; - // } + // let syncId; + log.debug('before cloud syncId:', syncResult.cloudData.syncId); + log.debug('before local syncId:', syncResult.localData.syncId); - // return [...acc, ...group.tabs.map(Tabs.extractId)]; - // }, []); + if (syncResult.changes.cloud) { + syncResult.changes.local = true; // syncId must be equal in cloud and local + syncResult.localData.syncId = syncResult.cloudData.syncId = Date.now(); + } else if (syncResult.changes.local) { // will be true if syncResult.sourceOfTruth === TRUTH_CLOUD + syncResult.localData.syncId = syncResult.cloudData.syncId; + } - let syncId; + // if (syncResult.sourceOfTruth === TRUTH_CLOUD) { + // if (syncResult.changes.cloud) { + // syncResult.localData.syncId = syncResult.cloudData.syncId = Date.now(); + // } else { + // // syncResult.changes.local = true; // TODO check if its needed + // syncResult.localData.syncId = syncResult.cloudData.syncId; + // } + // } else if (syncResult.sourceOfTruth === TRUTH_LOCAL) { - if (syncResult.sourceOfTruth === TRUTH_CLOUD) { - syncId = syncResult.changes.cloud ? Date.now() : syncResult.cloudData.syncId; - } else { - syncId = Date.now(); - } + // // syncResult.changes.local never be TRUE, + // // ONLY (!!!) if local options key doesn't exist, than it clone from cloud + + // if (syncResult.changes.cloud) { + // syncResult.localData.syncId = syncResult.cloudData.syncId = Date.now(); + // } + // } - syncResult.cloudData.syncId = syncResult.localData.syncId = syncId; + log.debug('trust:', syncResult.sourceOfTruth); + log.debug('changes.cloud:', syncResult.changes.cloud, syncResult.cloudData.syncId); + log.debug('changes.local:', syncResult.changes.local, syncResult.localData.syncId); + + // syncResult.cloudData.syncId = syncResult.localData.syncId = syncId; // TODO syncData for all tabs: Date.now() + tab.url // await Promise.all(allTabIds.map(tabId => Cache.setSyncId(tabId, syncId))); if (GithubGistCloud.gistId) { - try { - await GithubGistCloud.updateGist(syncResult.cloudData); - } catch (e) { - throw new CloudError('cantUploadBackupToGithubGist'); + if (syncResult.changes.cloud) { + try { + await GithubGistCloud.updateGist(syncResult.cloudData); + } catch (e) { + throw new CloudError('githubCantUploadBackupToGist'); + } } } else { try { const result = await GithubGistCloud.createGist(syncResult.cloudData, 'Simple Tab Groups backup'); await saveNewGistId(result.id); } catch (e) { - throw new CloudError('cantCreateBackupIntoGithubGist'); + throw new CloudError('githubCantCreateBackupIntoGist'); } } - // TODO normal save options - await Storage.set(syncResult.localData); + if (syncResult.changes.local) { + // TODO normal save options + await Storage.set(syncResult.localData); + } log.stop(); @@ -203,6 +228,8 @@ async function syncData(localData, cloudData = null) { cloud: !hasCloudData, }; + // don't care if cloudData not exist before, and it's clone of local data + await mapContainers(localData, cloudData); await syncOptions(localData, cloudData, sourceOfTruth, changes); @@ -262,34 +289,22 @@ async function syncGroups(localData, cloudData, sourceOfTruth, changes) { if (resultLocalGroup.dontUploadToCloud) { // TODO check & do this on all code resultLocalGroups.push(resultLocalGroup); - return; - } - - resultCloudGroup.tabs = prepareForSave(resultLocalGroup.tabs, false); - - /* if (resultCloudGroup.isArchive) { - resultCloudGroup.tabs = resultLocalGroup.tabs.map(tab => { - const tabToCloud = {...tab}; + } else { + resultCloudGroup.tabs = prepareForSave(resultLocalGroup.tabs, false); - delete tabToCloud.id; - delete tabToCloud.openerTabId; - delete tabToCloud.thumbnail; - delete tabToCloud.groupId; // ???? TODO check need it? - // delete tabToCloud.noSync; // TODO remove it ???? + resultLocalGroups.push(resultLocalGroup); + resultCloudGroups.push(resultCloudGroup); + } + }); - if (!localData.syncTabFavIcons) { - delete tabToCloud.favIconUrl; - } + if (!changes.cloud) { + changes.cloud = resultCloudGroups.length !== cloudGroups.length; + } - return tabToCloud; - }); - } else { - resultCloudGroup.tabs = Tabs.prepareForSave(resultLocalGroup.tabs, false, localData.syncTabFavIcons, false, false); - } */ + if (!changes.cloud) { + changes.cloud = JSON.stringify(resultCloudGroups) !== JSON.stringify(cloudGroups); + } - resultLocalGroups.push(resultLocalGroup); - resultCloudGroups.push(resultCloudGroup); - }); } else if (sourceOfTruth === TRUTH_CLOUD) { // const localTabsToRemove = new Set; @@ -586,29 +601,59 @@ function assignGroupKeys(localGroup, cloudGroup, sourceOfTruth, changes) { async function syncOptions(localData, cloudData, sourceOfTruth, changes) { const log = logger.start('syncOptions', {sourceOfTruth}); - const EXCLUDE_OPTION_KEYS = [ + const EXCLUDE_OPTION_KEY_STARTS_WITH = [ 'defaultGroupProps', 'autoBackup', 'sync', ]; for (const key of Constants.ALL_OPTIONS_KEYS) { - if (EXCLUDE_OPTION_KEYS.some(exKey => key.startsWith(exKey))) { + if (EXCLUDE_OPTION_KEY_STARTS_WITH.some(exKey => key.startsWith(exKey))) { continue; } + // this code below used for "number", "strings", "array" option values, + // and can be used for object values without any changes const jsonLocalValue = JSON.stringify(localData[key]); const jsonCloudValue = JSON.stringify(cloudData[key]); + if (jsonLocalValue === undefined || jsonCloudValue === undefined) { + if (jsonLocalValue === undefined) { + log.warn(`local options key "${key}" is undefined. creating it.`); + + if (sourceOfTruth === TRUTH_LOCAL || jsonCloudValue === undefined) { + localData[key] = JSON.clone(Constants.DEFAULT_OPTIONS[key]); + } else { + localData[key] = JSON.parse(jsonCloudValue); + } + + changes.local = true; + } + + if (jsonCloudValue === undefined) { + log.warn(`cloud options key "${key}" is undefined. creating it.`); + + if (sourceOfTruth === TRUTH_CLOUD || jsonLocalValue === undefined) { + cloudData[key] = JSON.clone(Constants.DEFAULT_OPTIONS[key]); + } else { + cloudData[key] = JSON.parse(jsonLocalValue); + } + + changes.cloud = true; + } + + continue; + } + if (jsonLocalValue !== jsonCloudValue) { if (sourceOfTruth === TRUTH_LOCAL) { cloudData[key] = JSON.parse(jsonLocalValue); changes.cloud = true; - log.log('cloudChanged:', key); + log.log('cloud has changed options key:', key); } else if (sourceOfTruth === TRUTH_CLOUD) { localData[key] = JSON.parse(jsonCloudValue); changes.local = true; - log.log('localChanged:', key); + log.log('local has changed options key:', key); } } } diff --git a/addon/src/js/sync/cloud/githubgist.js b/addon/src/js/sync/cloud/githubgist.js index ba617043..ee66456f 100644 --- a/addon/src/js/sync/cloud/githubgist.js +++ b/addon/src/js/sync/cloud/githubgist.js @@ -12,57 +12,6 @@ const GISTS_GLOBAL_HEADERS = { 'Content-Type': 'application/json', }; -async function github(method, url, token, body, headers = {}) { - method = method.toUpperCase(); - - const options = { - method, - headers: { - ...GISTS_GLOBAL_HEADERS, - ...headers, - Authorization: `token ${token}`, - }, - }; - - if (method !== 'GET' && body) { - if (body.files) { - for (const value of Object.values(body.files)) { - if (value.content && typeof value.content !== 'string') { - value.content = JSON.stringify(value.content, 2); - } - } - } - - options.body = JSON.stringify(body); - } - - if (Array.isArray(url)) { - url = Utils.setUrlSearchParams(url[0], url[1]); - } - - const response = await fetch(url, options); - - if (response.ok) { - return response.json(); - } - - let message = null; - - if (response.headers.get('content-type')?.includes('json')) { - ({message} = await response.json()); - } else { - message = await response.text(); - } - - message = message.slice(0, 150); - - const errorMessage = `GitHub Gist error: ${response.status} ${response.statusText}\n${message}`; - - console.error(errorMessage); - - throw Error(errorMessage); -} - export default function GithubGist(token, fileName, gistId = null) { this.token = token; this.fileName = fileName; @@ -70,29 +19,18 @@ export default function GithubGist(token, fileName, gistId = null) { } GithubGist.prototype.checkToken = async function() { - if (!this.token) { - throw Error('token is invalid'); - } - - await github('get', [GISTS_URL, { - page: 1, - per_page: 1, - }], this.token); - - return true; + await this.request('get', GITHUB_API_URL); } GithubGist.prototype.findGistId = async function() { this.gistId = null; - return this.gistId = await findGistId(this.token, this.fileName); + return this.gistId = await findGistId.call(this, this.fileName); } -async function findGistId(token, fileName, page = 1) { - const gists = await github('get', [GISTS_URL, { +async function findGistId(fileName, page = 1) { + const gists = await this.request('get', GISTS_URL, { page, per_page: GISTS_PER_PAGE, - }], token, { - // cache: 'no-store', }), gist = gists.find(g => !g.public && g.files.hasOwnProperty(fileName)); @@ -100,25 +38,29 @@ async function findGistId(token, fileName, page = 1) { return gist.id; } else if (gists.length === GISTS_PER_PAGE) { if ((page * GISTS_PER_PAGE) > 1000) { - throw Error('You have too many gists.\nCreate gist and write id in addon options'); + throw Error('You have too many gists.\nCreate gist and write id in addon options'); // TODO move to lang } - return findGistId(token, fileName, ++page); + return findGistId.call(this, fileName, ++page); } return null; } GithubGist.prototype.getGist = async function() { - const data = await github('get', `${GISTS_URL}/${this.gistId}`, this.token), + if (!this.gistId) { + throw Error('githubNotFound'); + } + + const data = await this.request('get', `${GISTS_URL}/${this.gistId}`, undefined, {cache: 'no-store'}), file = data.files[this.fileName]; - if (!file?.content) { - throw Error('File in gist not found'); + if (file?.truncated) { + file.content = await fetch(file.raw_url).then(res => res.json()); } - if (file.truncated) { - file.content = await fetch(file.raw_url).then(res => res.json()); + if (!file?.content) { + throw Error('githubNotFound'); } return { @@ -128,7 +70,7 @@ GithubGist.prototype.getGist = async function() { } GithubGist.prototype.createGist = async function(content, description = '') { - return github('post', GISTS_URL, this.token, { + return this.request('post', GISTS_URL, { public: false, description, files: { @@ -138,7 +80,7 @@ GithubGist.prototype.createGist = async function(content, description = '') { } GithubGist.prototype.updateGist = async function(content) { - return github('patch', `${GISTS_URL}/${this.gistId}`, this.token, { + return this.request('patch', `${GISTS_URL}/${this.gistId}`, { files: { [this.fileName]: {content}, }, @@ -146,7 +88,7 @@ GithubGist.prototype.updateGist = async function(content) { } GithubGist.prototype.renameGist = async function(newFileName) { - const result = await github('patch', `${GISTS_URL}/${this.gistId}`, this.token, { + const result = await this.request('patch', `${GISTS_URL}/${this.gistId}`, { files: { [this.fileName]: {newFileName}, }, @@ -156,3 +98,73 @@ GithubGist.prototype.renameGist = async function(newFileName) { return result; } + +GithubGist.prototype.request = async function(method, url, body = {}, reqOptions = {}) { + if (!this.token) { + throw Error('githubInvalidToken'); + } + + method = method.toUpperCase(); + + const headers = reqOptions.headers ?? {}; + delete reqOptions.headers; + + const options = { + method, + headers: { + ...GISTS_GLOBAL_HEADERS, + Authorization: `token ${this.token}`, + ...headers, + }, + ...reqOptions, + }; + + if (method === 'GET') { + url = Utils.setUrlSearchParams(url, body); + } else if (body) { + if (body.files) { + for (const value of Object.values(body.files)) { + if (value.content && typeof value.content !== 'string') { + value.content = JSON.stringify(value.content, 2); + } + } + } + + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + const hasGistAccess = response.headers.get('x-oauth-scopes')?.split(/\s*,\s*/).includes('gist'); + + if (!hasGistAccess) { + throw Error('githubTokenNoAccess'); + } + + if (response.ok) { + return response.json(); + } + + if (response.status === 404) { + throw Error('githubNotFound'); + } + + throw Error('githubInvalidToken'); + + // TMP + // let message = null; + + // if (response.headers.get('content-type')?.includes('json')) { + // ({message} = await response.json()); + // } else { + // message = await response.text(); + // } + + // message = message.slice(0, 150); + + // const errorMessage = `GitHub Gist error: ${response.status} ${response.statusText}\n${message}`; + + // console.error(errorMessage); + + // throw Error(errorMessage); +} diff --git a/addon/src/options/github-gist-fields.vue b/addon/src/options/github-gist-fields.vue index 3b16c8e9..57f4372a 100644 --- a/addon/src/options/github-gist-fields.vue +++ b/addon/src/options/github-gist-fields.vue @@ -67,18 +67,19 @@ export default { lang: browser.i18n.getMessage, async checkToken() { - this.tokenLoading = true; - try { + this.tokenLoading = true; this.tokenCheched = null; this.GithubGistCloud.token = this.token; await this.GithubGistCloud.checkToken(); this.tokenCheched = true; + this.$emit('update:error', ''); } catch (e) { + this.$emit('update:error', e.message); this.tokenCheched = false; + } finally { + this.tokenLoading = false; } - - this.tokenLoading = false; }, }, } @@ -97,7 +98,7 @@ export default { 'is-loading': tokenLoading, 'has-icons-right': tokenCheched !== null, }]"> - + @@ -136,6 +137,7 @@ export default { + diff --git a/addon/src/options/github-gist.vue b/addon/src/options/github-gist.vue index 971bacce..40c7d4e9 100644 --- a/addon/src/options/github-gist.vue +++ b/addon/src/options/github-gist.vue @@ -26,18 +26,32 @@ export default { }, sync: { + title: 'Use settings located in Firefox synchronisation', + disabled: !this.SYNC_STORAGE_IS_AVAILABLE, loading: false, + value: this.SYNC_STORAGE_FSYNC, options: {...Constants.DEFAULT_SYNC_OPTIONS}, load: this.loadSyncOptions.bind(this), save: this.saveSyncOptions.bind(this), error: '', + icon: { + load: '/icons/cloud-arrow-down-solid.svg', + save: '/icons/cloud-arrow-up-solid.svg', + }, }, local: { + title: 'Use settings located locally in the current browser profile', + disabled: false, loading: false, + value: this.SYNC_STORAGE_LOCAL, options: {...Constants.DEFAULT_SYNC_OPTIONS, syncOptionsLocation: Constants.DEFAULT_OPTIONS.syncOptionsLocation}, load: this.loadLocalOptions.bind(this), save: this.saveLocalOptions.bind(this), error: '', + icon: { + load: '/icons/arrow-down.svg', + save: '/icons/floppy-disk-solid.svg', + }, }, }; }, @@ -53,6 +67,12 @@ export default { browserName() { return `${this.browserInfo.name} v${this.browserInfo.version}`; }, + areas() { + return [this.sync, this.local]; + }, + area() { + return this.areas.find(area => area.value === this.local.options.syncOptionsLocation); + }, }, created() { this.sync.load(); @@ -88,17 +108,19 @@ export default { // MAIN async save(area) { - area.error = ''; - area.loading = true; + try { + area.error = ''; + area.loading = true; - await area.save(); + await area.save(); - try { const result = await Cloud.sync(); // const result = await this.createBackup(area.options); console.debug('result', result) + await area.load(); + // if (result.newGistId) { // area.options.githubGistId = result.newGistId; @@ -183,7 +205,20 @@ export default { - + + + + + + + + + + + + + + Your browser is: {{browserName}}, it doesn't support Firefox Sync @@ -195,74 +230,30 @@ export default { - - - - - Use settings that are in Firefox Sync - - - + - + - + Load - + - + - Save settings - Save settings and create/update backup - - - - - - - - - - Use settings that are on the local computer (this $browserName$ profile) - - - - - - - - - - - - Load - - - - - - - - Save settings + Save settings Save settings and create/update backup @@ -282,14 +273,4 @@ html[data-theme="dark"] .box .subtitle { color: #cecece; } -fieldset { - padding: .75rem; - border: 1px solid; -} - -legend { - padding: 0 calc(.75rem / 2); - margin: 0 .75rem; -} -
Your browser is: {{browserName}}, it doesn't support Firefox Sync
@@ -195,74 +230,30 @@ export default {