mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-11 00:20:54 +00:00
4.20.60
* Added: Backup and Restore now supports backing up any binary data stored in FFZ settings, creating a `zip` file rather than a `json` file. * Fixed: Remove dead code from the `clear-settings` menu component. * Changed: Update the theme mapping to include missing elements. * Changed: Data Management > Storage >> Provider now indicates if a provider supports storing binary data. * Changed: Update the link-parsing regex to match Twitch. Currently under limited roll-out while ensuring the implementation is bug-free. * API Added: `setting-hotkey` now functions as would be expected and can be used. * API Changed: A setting's `onUIChange` method now has the Vue component as its second argument, for getting any necessary state from the settings UI. * API Changed: Providers now sanity check the format of Blobs before storing them.
This commit is contained in:
parent
5412a928a1
commit
2c5937c8af
20 changed files with 574 additions and 138 deletions
|
@ -13,6 +13,7 @@ import {CATEGORIES} from './emoji';
|
|||
|
||||
const EMOTE_CLASS = 'chat-image chat-line__message--emote',
|
||||
LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g,
|
||||
NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g,
|
||||
//MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex
|
||||
MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex
|
||||
|
||||
|
@ -146,6 +147,8 @@ export const Links = {
|
|||
if ( ! tokens || ! tokens.length )
|
||||
return tokens;
|
||||
|
||||
const use_new = this.experiments.getAssignment('new_links');
|
||||
|
||||
const out = [];
|
||||
for(const token of tokens) {
|
||||
if ( token.type !== 'text' ) {
|
||||
|
@ -154,24 +157,43 @@ export const Links = {
|
|||
}
|
||||
|
||||
LINK_REGEX.lastIndex = 0;
|
||||
NEW_LINK_REGEX.lastIndex = 0;
|
||||
const text = token.text;
|
||||
let idx = 0, match;
|
||||
|
||||
while((match = LINK_REGEX.exec(text))) {
|
||||
const nix = match.index + (match[1] ? match[1].length : 0);
|
||||
if ( idx !== nix )
|
||||
out.push({type: 'text', text: text.slice(idx, nix)});
|
||||
if ( use_new ) {
|
||||
while((match = NEW_LINK_REGEX.exec(text))) {
|
||||
const nix = match.index;
|
||||
if ( idx !== nix )
|
||||
out.push({type: 'text', text: text.slice(idx, nix)});
|
||||
|
||||
const is_mail = ! match[3] && match[2].indexOf('/') === -1 && match[2].indexOf('@') !== -1;
|
||||
out.push({
|
||||
type: 'link',
|
||||
url: `${match[1] ? '' : 'https://'}${match[0]}`,
|
||||
is_mail: false,
|
||||
text: match[0]
|
||||
});
|
||||
|
||||
out.push({
|
||||
type: 'link',
|
||||
url: (match[3] ? '' : is_mail ? 'mailto:' : 'https://') + match[2],
|
||||
is_mail,
|
||||
text: match[2]
|
||||
});
|
||||
idx = nix + match[0].length;
|
||||
}
|
||||
|
||||
idx = nix + match[2].length;
|
||||
} else {
|
||||
while((match = LINK_REGEX.exec(text))) {
|
||||
const nix = match.index + (match[1] ? match[1].length : 0);
|
||||
if ( idx !== nix )
|
||||
out.push({type: 'text', text: text.slice(idx, nix)});
|
||||
|
||||
const is_mail = ! match[3] && match[2].indexOf('/') === -1 && match[2].indexOf('@') !== -1;
|
||||
|
||||
out.push({
|
||||
type: 'link',
|
||||
url: (match[3] ? '' : is_mail ? 'mailto:' : 'https://') + match[2],
|
||||
is_mail,
|
||||
text: match[2]
|
||||
});
|
||||
|
||||
idx = nix + match[2].length;
|
||||
}
|
||||
}
|
||||
|
||||
if ( idx < text.length )
|
||||
|
|
|
@ -66,11 +66,11 @@ export default {
|
|||
this.error = false;
|
||||
this.message = null;
|
||||
|
||||
let blob;
|
||||
let file;
|
||||
try {
|
||||
const settings = this.item.getFFZ().resolve('settings'),
|
||||
data = await settings.getFullBackup();
|
||||
blob = new Blob([JSON.stringify(data)], {type: 'application/json;charset=utf-8'});
|
||||
const settings = this.item.getFFZ().resolve('settings');
|
||||
file = await settings.generateBackupFile();
|
||||
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.dump-error', 'Unable to export settings data to JSON.');
|
||||
this.error = true;
|
||||
|
@ -78,7 +78,7 @@ export default {
|
|||
}
|
||||
|
||||
try {
|
||||
saveAs(blob, 'ffz-settings.json');
|
||||
saveAs(file, file.name);
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.save-error', 'Unable to save.');
|
||||
}
|
||||
|
@ -88,9 +88,22 @@ export default {
|
|||
this.error = false;
|
||||
this.message = null;
|
||||
|
||||
let file;
|
||||
try {
|
||||
file = await openFile('application/json,application/zip');
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.read-error', 'Unable to read file.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// We might get a different MIME than expected, roll with it.
|
||||
if ( file.type.toLowerCase().includes('zip') )
|
||||
return this.restoreZip(file);
|
||||
|
||||
let contents;
|
||||
try {
|
||||
contents = await readFile(await openFile('application/json'));
|
||||
contents = await readFile(file);
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.read-error', 'Unable to read file.');
|
||||
this.error = true;
|
||||
|
@ -135,6 +148,101 @@ export default {
|
|||
this.message = this.t('setting.backup-restore.restored', '{count,number} items have been restored. Please refresh this page.', {
|
||||
count: i
|
||||
});
|
||||
},
|
||||
|
||||
async restoreZip(file) {
|
||||
const JSZip = (await import(/* webpackChunkName: "zip" */ 'jszip')).default;
|
||||
let input, blobs, data;
|
||||
|
||||
try {
|
||||
input = await (new JSZip().loadAsync(file));
|
||||
|
||||
blobs = await input.file('blobs.json').async('text');
|
||||
data = await input.file('settings.json').async('text');
|
||||
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.zip-error', 'Unable to parse ZIP archive.');
|
||||
this.error = true;
|
||||
}
|
||||
|
||||
try {
|
||||
blobs = JSON.parse(blobs);
|
||||
data = JSON.parse(data);
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.json-error', 'Unable to parse file as JSON.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! data || data.version !== 2 ) {
|
||||
this.error_desc = this.t('setting.backup-restore.old-file', 'This file is invalid or was created in another version of FrankerFaceZ and cannot be loaded.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( data.type !== 'full' ) {
|
||||
this.error_desc = this.t('setting.backup-restore.non-full', 'This file is not a full backup and cannot be restored with this tool.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = this.item.getFFZ().resolve('settings');
|
||||
await settings.awaitProvider();
|
||||
const provider = settings.provider;
|
||||
await provider.awaitReady();
|
||||
|
||||
if ( Object.keys(blobs).length && ! provider.supportsBlobs ) {
|
||||
this.error_desc = this.t('setting.backup-restore.blob-error', 'This backup contains binary data not supported by the current storage provider. Please change your storage provider in Data Management > Storage >> Provider.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to load all the blobs, to make sure they're all valid.
|
||||
const loaded_blobs = {};
|
||||
|
||||
for(const [safe_key, data] of Object.entries(blobs)) {
|
||||
let blob;
|
||||
if ( data.type === 'file' ) {
|
||||
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
|
||||
blob = new File([blob], data.name, {lastModified: data.modified, type: data.mime});
|
||||
} else if ( data.type === 'blob' )
|
||||
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
|
||||
else if ( data.type === 'ab' )
|
||||
blob = await input.file(`blobs/${safe_key}`).async('arraybuffer'); // eslint-disable-line no-await-in-loop
|
||||
else if ( data.type === 'ui8' )
|
||||
blob = await input.file(`blobs/${safe_key}`).async('uint8array'); // eslint-disable-line no-await-in-loop
|
||||
else {
|
||||
this.error_desc = this.t('setting.backup-restore.invalid-blob', 'This file contains a binary blob with an invalid type: {type}', data);
|
||||
this.error = true;
|
||||
}
|
||||
|
||||
loaded_blobs[data.key] = blob;
|
||||
}
|
||||
|
||||
// We've loaded all data, let's get this installed.
|
||||
// Blobs first.
|
||||
let b = 0;
|
||||
await provider.clearBlobs();
|
||||
|
||||
for(const [key, blob] of Object.entries(loaded_blobs)) {
|
||||
await provider.setBlob(key, blob); // eslint-disable-line no-await-in-loop
|
||||
b++;
|
||||
}
|
||||
|
||||
// Settings second.
|
||||
provider.clear();
|
||||
let i = 0;
|
||||
for(const key of Object.keys(data.values)) {
|
||||
const val = data.values[key];
|
||||
provider.set(key, val);
|
||||
provider.emit('changed', key, val, false);
|
||||
i++;
|
||||
}
|
||||
|
||||
this.message = this.t('setting.backup-restore.zip-restored', '{count,number} items and {blobs,number} binary blobs have been restored. Please refresh this page.', {
|
||||
count: i,
|
||||
blobs: b
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,81 +134,6 @@ export default {
|
|||
}
|
||||
|
||||
this.running = false;
|
||||
},
|
||||
|
||||
async backup() {
|
||||
this.error = false;
|
||||
this.message = null;
|
||||
|
||||
let blob;
|
||||
try {
|
||||
const settings = this.item.getFFZ().resolve('settings'),
|
||||
data = await settings.getFullBackup();
|
||||
blob = new Blob([JSON.stringify(data)], {type: 'application/json;charset=utf-8'});
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.dump-error', 'Unable to export settings data to JSON.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveAs(blob, 'ffz-settings.json');
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.save-error', 'Unable to save.');
|
||||
}
|
||||
},
|
||||
|
||||
async restore() {
|
||||
this.error = false;
|
||||
this.message = null;
|
||||
|
||||
let contents;
|
||||
try {
|
||||
contents = await readFile(await openFile('application/json'));
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.read-error', 'Unable to read file.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(contents);
|
||||
} catch(err) {
|
||||
this.error_desc = this.t('setting.backup-restore.json-error', 'Unable to parse file as JSON.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! data || data.version !== 2 ) {
|
||||
this.error_desc = this.t('setting.backup-restore.old-file', 'This file is invalid or was created in another version of FrankerFaceZ and cannot be loaded.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( data.type !== 'full' ) {
|
||||
this.error_desc = this.t('setting.backup-restore.non-full', 'This file is not a full backup and cannot be restored with this tool.');
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = this.item.getFFZ().resolve('settings'),
|
||||
provider = settings.provider;
|
||||
|
||||
await provider.awaitReady();
|
||||
|
||||
provider.clear();
|
||||
let i = 0;
|
||||
for(const key of Object.keys(data.values)) {
|
||||
const val = data.values[key];
|
||||
provider.set(key, val);
|
||||
provider.emit('changed', key, val, false);
|
||||
i++;
|
||||
}
|
||||
|
||||
this.message = this.t('setting.backup-restore.restored', '{count,number} items have been restored. Please refresh this page.', {
|
||||
count: i
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -276,9 +276,20 @@ export default {
|
|||
async doImport() {
|
||||
this.resetImport();
|
||||
|
||||
let contents;
|
||||
let file, contents;
|
||||
try {
|
||||
contents = await readFile(await openFile('application/json'));
|
||||
file = await openFile('application/json,application/zip');
|
||||
|
||||
// We might get a different MIME than expected, roll with it.
|
||||
if ( file.type.toLowerCase().includes('zip') ) {
|
||||
const JSZip = (await import(/* webpackChunkName: "zip" */ 'jszip')).default,
|
||||
zip = await (new JSZip().loadAsync(file));
|
||||
|
||||
contents = await zip.file('settings.json').async('text');
|
||||
|
||||
} else
|
||||
contents = await readFile(file);
|
||||
|
||||
} catch(err) {
|
||||
this.import_error = true;
|
||||
this.import_error_message = this.t('setting.backup-restore.read-error', 'Unable to read file.');
|
||||
|
|
|
@ -38,6 +38,9 @@
|
|||
<span v-if="val.has_data" class="tw-mg-l-1 tw-c-text-alt">
|
||||
{{ t('setting.provider.has-data', '(Has Data)') }}
|
||||
</span>
|
||||
<span v-if="val.has_blobs" class="tw-mg-l-1 tw-c-text-alt">
|
||||
{{ t('setting.provider.has-blobs', '(Supports Binary Data)') }}
|
||||
</span>
|
||||
</div>
|
||||
<section v-if="val.description" class="tw-c-text-alt-2">
|
||||
<markdown :source="t(val.desc_i18n_key, val.description)" />
|
||||
|
@ -100,6 +103,7 @@ export default {
|
|||
const prov = {
|
||||
key,
|
||||
has_data: null,
|
||||
has_blobs: val.supportsBlobs,
|
||||
i18n_key: `setting.provider.${key}.title`,
|
||||
title: val.title || key,
|
||||
desc_i18n_key: val.description ? `setting.provider.${key}.desc` : null,
|
||||
|
|
|
@ -1,25 +1,47 @@
|
|||
<template lang="html">
|
||||
<div class="ffz--widget ffz--hotkey-input">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<div class="tw-input__icon-group tw-input__icon-group--right">
|
||||
<div class="tw-input__icon">
|
||||
<figure class="ffz-i-keyboard" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
:class="{inherits: isInherited, default: isDefault}"
|
||||
class="ffz--widget ffz--hotkey-input"
|
||||
>
|
||||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
<key-picker
|
||||
:id="item.full_key"
|
||||
ref="display"
|
||||
type="text"
|
||||
class="tw-mg-05 tw-input tw-input--icon-right"
|
||||
tabindex="0"
|
||||
@keyup="onKey"
|
||||
ref="control"
|
||||
:value="value"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<component
|
||||
:is="item.buttons"
|
||||
v-if="item.buttons"
|
||||
:context="context"
|
||||
:item="item"
|
||||
:value="value"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="source && source !== profile"
|
||||
class="tw-mg-l-05 tw-button tw-button--text"
|
||||
@click="context.currentProfile = source"
|
||||
>
|
||||
|
||||
</div>
|
||||
<span class="tw-button__text ffz-i-right-dir">
|
||||
{{ sourceDisplay }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text tw-tooltip__container" @click="clear">
|
||||
<span class="tw-button__text ffz-i-cancel" />
|
||||
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
|
||||
{{ t('setting.reset', 'Reset to Default') }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
|
@ -34,13 +56,15 @@
|
|||
|
||||
<script>
|
||||
|
||||
import SettingMixin from '../setting-mixin';
|
||||
|
||||
export default {
|
||||
mixins: [SettingMixin],
|
||||
props: ['item', 'context'],
|
||||
|
||||
methods: {
|
||||
onKey(e) {
|
||||
const name = `${e.ctrlKey ? 'Ctrl-' : ''}${e.shiftKey ? 'Shift-' : ''}${e.altKey ? 'Alt-' : ''}${e.code}`;
|
||||
this.$refs.display.innerText = name;
|
||||
onInput(value) {
|
||||
this.set(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ export default {
|
|||
this.value = deep_copy(value);
|
||||
|
||||
if ( this.item.onUIChange )
|
||||
this.item.onUIChange(this.value);
|
||||
this.item.onUIChange(this.value, this);
|
||||
},
|
||||
|
||||
clear() {
|
||||
|
@ -102,7 +102,7 @@ export default {
|
|||
this.has_value = false;
|
||||
|
||||
if ( this.item.onUIChange )
|
||||
this.item.onUIChange(this.value);
|
||||
this.item.onUIChange(this.value, this);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -92,6 +92,13 @@ export default {
|
|||
return this.source && this.sourceOrder < this.profileOrder;
|
||||
},
|
||||
|
||||
isValid() {
|
||||
if ( typeof this.item.validator === 'function' )
|
||||
return this.item.validator(this.value, this);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
sourceOrder() {
|
||||
return this.source ? this.source.order : Infinity
|
||||
},
|
||||
|
@ -186,14 +193,14 @@ export default {
|
|||
this.profile.set(this.item.setting, value);
|
||||
|
||||
if ( this.item.onUIChange )
|
||||
this.item.onUIChange(value);
|
||||
this.item.onUIChange(value, this);
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.profile.delete(this.item.setting);
|
||||
|
||||
if ( this.item.onUIChange )
|
||||
this.item.onUIChange(this.value);
|
||||
this.item.onUIChange(this.value, this);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue