added backup apis and tests

This commit is contained in:
Gani Georgiev
2023-05-13 22:10:14 +03:00
parent 3b0f60fe15
commit e8b4a7eb26
104 changed files with 3192 additions and 1017 deletions
@@ -82,14 +82,15 @@
confirmClose = false;
hide();
addSuccessToast(admin.$isNew ? "Successfully created admin." : "Successfully updated admin.");
dispatch("save", result);
if (ApiClient.authStore.model?.id === result.id) {
ApiClient.authStore.save(ApiClient.authStore.token, result);
}
dispatch("save", result);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
})
.finally(() => {
isSaving = false;
@@ -111,7 +112,7 @@
dispatch("delete", admin);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
@@ -26,7 +26,7 @@
addSuccessToast("Successfully set a new admin password.");
replace("/");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -19,7 +19,7 @@
await ApiClient.admins.requestPasswordReset(email);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
+1 -1
View File
@@ -58,7 +58,7 @@
isLoading = false;
console.warn(err);
clearList();
ApiClient.errorResponseHandler(err, false);
ApiClient.error(err, false);
}
});
}
+1 -1
View File
@@ -28,7 +28,7 @@
dispatch("submit");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
+4 -1
View File
@@ -7,6 +7,9 @@
let tooltipData = { text: "Refresh", position: "right" };
export { tooltipData as tooltip };
let classes = "";
export { classes as class };
let refreshTimeoutId = null;
function refresh() {
@@ -31,7 +34,7 @@
<button
type="button"
aria-label="Refresh"
class="btn btn-transparent btn-circle"
class="btn btn-transparent btn-circle {classes}"
class:refreshing={refreshTimeoutId}
use:tooltip={tooltipData}
on:click={refresh}
+3
View File
@@ -157,6 +157,9 @@
function handleOptionKeypress(e, item) {
if (e.code === "Enter" || e.code === "Space") {
handleOptionSelect(e, item);
if (closable) {
hideDropdown();
}
}
}
@@ -114,7 +114,7 @@
initialFormHash = calculateFormHash(collection);
}
function saveWithConfirm() {
function saveConfirm() {
if (collection.$isNew) {
save();
} else {
@@ -159,7 +159,7 @@
});
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
})
.finally(() => {
isSaving = false;
@@ -196,7 +196,7 @@
removeCollection(original);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
@@ -304,7 +304,7 @@
<form
class="block"
on:submit|preventDefault={() => {
canSave && saveWithConfirm();
canSave && saveConfirm();
}}
>
<Field
@@ -453,7 +453,7 @@
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!canSave || isSaving}
on:click={() => saveWithConfirm()}
on:click={() => saveConfirm()}
>
<span class="txt">{collection.$isNew ? "Create" : "Save changes"}</span>
</button>
+1 -1
View File
@@ -59,7 +59,7 @@
if (!err?.isAbort) {
resetData();
console.warn(err);
ApiClient.errorResponseHandler(err, false);
ApiClient.error(err, false);
}
})
.finally(() => {
+1 -1
View File
@@ -70,7 +70,7 @@
isLoading = false;
console.warn(err);
clearList();
ApiClient.errorResponseHandler(err, false);
ApiClient.error(err, false);
}
});
}
@@ -33,7 +33,7 @@
try {
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -53,7 +53,7 @@
loadExternalAuths(); // reload list
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
@@ -28,7 +28,7 @@
await client.collection(payload.collectionId).confirmEmailChange(params?.token, password);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -31,7 +31,7 @@
.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -206,7 +206,7 @@
dispatch("save", result);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
})
.finally(() => {
isSaving = false;
@@ -227,7 +227,7 @@
dispatch("delete", original);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
@@ -299,7 +299,7 @@
addSuccessToast(`Successfully sent verification email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
@@ -316,7 +316,7 @@
addSuccessToast(`Successfully sent password reset email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
});
});
}
+2 -2
View File
@@ -165,7 +165,7 @@
isLoading = false;
console.warn(err);
clearList();
ApiClient.errorResponseHandler(err, false);
ApiClient.error(err, false);
}
});
}
@@ -234,7 +234,7 @@
deselectAllRecords();
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
})
.finally(() => {
isDeleting = false;
@@ -107,7 +107,7 @@
list = CommonHelper.filterDuplicatesByKey(selected.concat(list));
}
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoadingSelected = false;
@@ -144,7 +144,7 @@
currentPage = result.page;
totalItems = result.totalItems;
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoadingList = false;
@@ -91,7 +91,7 @@
list = list;
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -51,7 +51,7 @@
hide();
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSubmitting = false;
@@ -0,0 +1,138 @@
<script>
import { createEventDispatcher, onDestroy } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { setErrors } from "@/stores/errors";
import { addInfoToast, addSuccessToast } from "@/stores/toasts";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
const formId = "backup_create_" + CommonHelper.randomString(5);
let panel;
let name = "";
let isSubmitting = false;
let submitTimeoutId;
export function show(newName) {
setErrors({});
isSubmitting = false;
name = newName || "";
panel?.show();
}
export function hide() {
return panel?.hide();
}
async function submit() {
if (isSubmitting) {
return;
}
isSubmitting = true;
clearTimeout(submitTimeoutId);
submitTimeoutId = setTimeout(() => {
hide();
}, 1500);
try {
await ApiClient.backups.create(name, { $cancelKey: formId });
isSubmitting = false;
hide();
dispatch("submit");
addSuccessToast("Successfully generated new backup.");
} catch (err) {
if (!err.isAbort) {
ApiClient.error(err);
}
}
clearTimeout(submitTimeoutId);
isSubmitting = false;
}
onDestroy(() => {
clearTimeout(submitTimeoutId);
});
</script>
<OverlayPanel
bind:this={panel}
class="backup-create-panel"
beforeOpen={() => {
if (isSubmitting) {
addInfoToast("A backup has already been started, please wait.");
return false;
}
return true;
}}
beforeHide={() => {
if (isSubmitting) {
addInfoToast(
"The backup was started but may take a while to complete. You can come back later.",
4500
);
}
return true;
}}
popup
on:show
on:hide
>
<svelte:fragment slot="header">
<h4 class="center txt-break">Initialize new backup</h4>
</svelte:fragment>
<div class="alert alert-info">
<div class="icon">
<i class="ri-information-line" />
</div>
<div class="content">
<p>
Please note that during the backup other concurrent write requrests may fail since the
database will be temporary "locked" (this usually happens only during the ZIP generation).
</p>
<p class="txt-bold">
If you are using S3 storage for the collections file upload, you'll have to backup them
separately since they are not locally stored and will not be included in the final backup!
</p>
</div>
</div>
<form id={formId} autocomplete="off" on:submit|preventDefault={submit}>
<Field class="form-field m-0" name="name" let:uniqueId>
<label for={uniqueId}>Backup name</label>
<input
type="text"
id={uniqueId}
placeholder={"Leave empty to autogenerate"}
pattern="^[a-z0-9_-]+\.zip$"
bind:value={name}
/>
<em class="help-block">Must be in the format [a-z0-9_-].zip</em>
</Field>
</form>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
<span class="txt">Cancel</span>
</button>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={isSubmitting}
>
<span class="txt">Start backup</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,114 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { setErrors } from "@/stores/errors";
import { addErrorToast } from "@/stores/toasts";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
const formId = "backup_restore_" + CommonHelper.randomString(5);
let panel;
let name = "";
let nameConfirm = "";
let isSubmitting = false;
$: canSubmit = nameConfirm != "" && name == nameConfirm;
export function show(backupName) {
setErrors({});
nameConfirm = "";
name = backupName;
isSubmitting = false;
panel?.show();
}
export function hide() {
return panel?.hide();
}
async function submit() {
if (!canSubmit || isSubmitting) {
return;
}
isSubmitting = true;
try {
await ApiClient.backups.restore(name);
// slight delay just in case the application is still restarting
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (err) {
if (!err?.isAbort) {
isSubmitting = false;
addErrorToast(err.response?.message || err.message);
}
}
}
</script>
<OverlayPanel
bind:this={panel}
class="backup-restore-panel"
overlayClose={!isSubmitting}
escClose={!isSubmitting}
beforeHide={() => !isSubmitting}
popup
on:show
on:hide
>
<svelte:fragment slot="header">
<h4 class="center txt-break">Restore <strong>{name}</strong></h4>
</svelte:fragment>
<div class="alert alert-danger">
<div class="icon">
<i class="ri-alert-line" />
</div>
<div class="content">
<p>Please proceed with caution.</p>
<p>
The restore operation will replace your existing <code>pb_data</code> with the one from the backup
and will restart the application process!
</p>
<p class="txt-bold">
Backup restore is still experimental and currently works only on UNIX based systems.
</p>
</div>
</div>
<div class="content m-b-sm">
Type the backup name
<div class="label">
<span class="txt">{name}</span>
<CopyIcon value={name} />
</div>
to confirm:
</div>
<form id={formId} autocomplete="off" on:submit|preventDefault={submit}>
<Field class="form-field required m-0" name="name" let:uniqueId>
<label for={uniqueId}>Backup name</label>
<input type="text" id={uniqueId} required bind:value={nameConfirm} />
</Field>
</form>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
Cancel
</button>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={!canSubmit || isSubmitting}
>
<span class="txt">Restore backup</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,224 @@
<script>
import { onMount } from "svelte";
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import BackupCreatePanel from "@/components/settings/BackupCreatePanel.svelte";
import BackupRestorePanel from "@/components/settings/BackupRestorePanel.svelte";
let createPanel;
let restorePanel;
let backups = [];
let isLoading = false;
let isDownloading = {};
let isDeleting = {};
let canBackup = true;
loadBackups();
loadCanBackup();
export async function loadBackups() {
isLoading = true;
try {
backups = await ApiClient.backups.getFullList();
// sort backups DESC by their modified date
backups.sort((a, b) => {
if (a.modified < b.modified) {
return 1;
}
if (a.modified > b.modified) {
return -1;
}
return 0;
});
isLoading = false;
} catch (err) {
if (!err.isAbort) {
ApiClient.error(err);
isLoading = false;
}
}
}
async function download(name) {
if (isDownloading[name]) {
return;
}
isDownloading[name] = true;
try {
const token = await ApiClient.getAdminFileToken();
const url = ApiClient.backups.getDownloadUrl(token, name);
CommonHelper.download(url);
} catch (err) {
if (!err.isAbort) {
ApiClient.error(err);
}
}
delete isDownloading[name];
isDownloading = isDownloading;
}
function deleteConfirm(name) {
confirm(`Do you really want to delete ${name}?`, () => deleteBackup(name));
}
async function deleteBackup(name) {
if (isDeleting[name]) {
return;
}
isDeleting[name] = true;
try {
await ApiClient.backups.delete(name);
CommonHelper.removeByKey(backups, "name", name);
loadBackups();
addSuccessToast(`Successfully deleted ${name}.`);
} catch (err) {
if (!err.isAbort) {
ApiClient.error(err);
}
}
delete isDeleting[name];
isDeleting = isDeleting;
}
async function loadCanBackup() {
try {
const health = await ApiClient.health.check({ $autoCancel: false });
const oldCanBackup = canBackup;
canBackup = health?.data?.canBackup || false;
// reload backups list
if (oldCanBackup != canBackup && canBackup) {
loadBackups();
}
} catch (_) {}
}
onMount(() => {
let canBackupIntervalId = setInterval(() => {
loadCanBackup();
}, 3000);
return () => {
clearInterval(canBackupIntervalId);
};
});
</script>
<div class="list list-compact">
<div class="list-content">
{#if isLoading}
{#each Array(backups.length || 1) as i}
<div class="list-item list-item-loader">
<span class="skeleton-loader" />
</div>
{/each}
{:else}
{#each backups as backup (backup.key)}
<div class="list-item" transition:slide|local={{ duration: 150 }}>
<i class="ri-folder-zip-line" />
<div class="content">
<span class="name backup-name">{backup.key}</span>
<span class="size txt-hint txt-nowrap">
({CommonHelper.formattedFileSize(backup.size)})
</span>
</div>
<div class="actions nonintrusive">
<button
type="button"
class="btn btn-sm btn-circle btn-hint btn-transparent"
class:btn-loading={isDownloading[backup.key]}
disabled={isDeleting[backup.key] || isDownloading[backup.key]}
aria-label="Download"
use:tooltip={"Download"}
on:click|preventDefault={() => download(backup.key)}
>
<i class="ri-download-line" />
</button>
<button
type="button"
class="btn btn-sm btn-circle btn-hint btn-transparent"
disabled={isDeleting[backup.key]}
aria-label="Restore"
use:tooltip={"Restore"}
on:click|preventDefault={() => restorePanel.show(backup.key)}
>
<i class="ri-restart-line" />
</button>
<button
type="button"
class="btn btn-sm btn-circle btn-hint btn-transparent"
class:btn-loading={isDeleting[backup.key]}
disabled={isDeleting[backup.key]}
aria-label="Delete"
use:tooltip={"Delete"}
on:click|preventDefault={() => deleteConfirm(backup.key)}
>
<i class="ri-delete-bin-7-line" />
</button>
</div>
</div>
{:else}
<div class="list-item list-item-placeholder">
<span class="txt">No backups yet.</span>
</div>
{/each}
{/if}
</div>
<div class="list-item list-item-btn">
<button
type="button"
class="btn btn-block btn-transparent"
disabled={isLoading || !canBackup}
on:click={() => createPanel?.show()}
>
{#if canBackup}
<i class="ri-play-circle-line" />
<span class="txt">Initialize new backup</span>
{:else}
<span class="loader loader-sm" />
<span class="txt">Backup/restore operation is in process</span>
{/if}
</button>
</div>
</div>
<BackupCreatePanel
bind:this={createPanel}
on:submit={() => {
loadBackups();
}}
/>
<BackupRestorePanel bind:this={restorePanel} />
<style lang="scss">
.list-content {
overflow: auto;
max-height: 342px;
.list-item {
min-height: 49px;
}
}
.backup-name {
max-width: 300px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -72,7 +72,7 @@
hide();
} catch (err) {
isSubmitting = false;
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
clearTimeout(testTimeoutId);
@@ -62,7 +62,7 @@
}
}
function submitWithConfirm() {
function submitConfirm() {
// find deleted fields
const deletedFieldNames = [];
if (deleteMissing) {
@@ -109,7 +109,7 @@
addSuccessToast("Successfully imported collections configuration.");
dispatch("submit");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isImporting = false;
@@ -137,13 +137,14 @@
{/each}
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isImporting}>Close</button>
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isImporting}>Close</button
>
<button
type="button"
class="btn btn-expanded"
class:btn-loading={isImporting}
disabled={isImporting}
on:click={() => submitWithConfirm()}
on:click={() => submitConfirm()}
>
<span class="txt">Confirm and import</span>
</button>
@@ -29,7 +29,7 @@
const settings = (await ApiClient.settings.getAll()) || {};
init(settings);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -47,7 +47,7 @@
init(settings);
addSuccessToast("Successfully saved application settings.");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSaving = false;
@@ -24,7 +24,7 @@
const result = (await ApiClient.settings.getAll()) || {};
initSettings(result);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -0,0 +1,293 @@
<script>
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { removeError } from "@/stores/errors";
import { addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import BackupsList from "@/components/settings/BackupsList.svelte";
import S3Fields from "@/components/settings/S3Fields.svelte";
$pageTitle = "Backups";
let backupsListComponent;
let originalFormSettings = {};
let formSettings = {};
let isLoading = false;
let isSaving = false;
let initialHash = "";
let enableAutoBackups = false;
let showBackupsSettings = false;
let isTesting = false;
let testError = null;
$: initialHash = JSON.stringify(originalFormSettings);
$: hasChanges = initialHash != JSON.stringify(formSettings);
$: if (!enableAutoBackups && formSettings?.backups?.cron) {
removeError("backups.cron");
formSettings.backups.cron = "";
}
loadSettings();
async function loadSettings() {
isLoading = true;
try {
const settings = (await ApiClient.settings.getAll()) || {};
init(settings);
} catch (err) {
ApiClient.error(err);
}
isLoading = false;
}
async function save() {
if (isSaving || !hasChanges) {
return;
}
isSaving = true;
try {
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
await refreshList();
init(settings);
addSuccessToast("Successfully saved application settings.");
} catch (err) {
ApiClient.error(err);
}
isSaving = false;
}
function init(settings = {}) {
formSettings = {
backups: settings?.backups || {},
};
enableAutoBackups = formSettings.backups.cron != "";
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
}
function reset() {
formSettings = JSON.parse(JSON.stringify(originalFormSettings || { backups: {} }));
enableAutoBackups = formSettings.backups.cron != "";
}
async function refreshList() {
await backupsListComponent?.loadBackups();
}
</script>
<SettingsSidebar />
<PageWrapper>
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
</header>
<div class="wrapper">
<div class="panel" autocomplete="off" on:submit|preventDefault={save}>
<div class="flex m-b-sm flex-gap-5">
<span class="txt-xl">Backup and restore your PocketBase data</span>
<RefreshButton
class="btn-sm"
tooltip={"Reload backups list"}
on:refresh={() => refreshList()}
/>
</div>
<BackupsList bind:this={backupsListComponent} />
<hr />
<button
type="button"
class="btn btn-secondary"
class:btn-loading={isLoading}
disabled={isLoading}
on:click={() => (showBackupsSettings = !showBackupsSettings)}
>
<span class="txt">Backups options</span>
{#if showBackupsSettings}
<i class="ri-arrow-up-s-line" />
{:else}
<i class="ri-arrow-down-s-line" />
{/if}
</button>
{#if showBackupsSettings && !isLoading}
<form
class="block"
autocomplete="off"
on:submit|preventDefault={save}
transition:slide|local={{ duration: 150 }}
>
<Field class="form-field form-field-toggle m-t-base m-b-0" let:uniqueId>
<input type="checkbox" id={uniqueId} required bind:checked={enableAutoBackups} />
<label for={uniqueId}>Enable auto backups</label>
</Field>
{#if enableAutoBackups}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="grid p-t-base p-b-sm">
<div class="col-lg-6">
<Field class="form-field required" name="backups.cron" let:uniqueId>
<label for={uniqueId}>Cron expression</label>
<!-- svelte-ignore a11y-autofocus -->
<input
required
type="text"
id={uniqueId}
class="txt-lg txt-mono"
placeholder="* * * * *"
autofocus={!originalFormSettings?.backups?.cron}
bind:value={formSettings.backups.cron}
/>
<div class="form-field-addon">
<button type="button" class="btn btn-sm btn-outline p-r-0">
<span class="txt">Presets</span>
<i class="ri-arrow-drop-down-fill" />
<Toggler class="dropdown dropdown-nowrap dropdown-right">
<button
type="button"
class="dropdown-item closable"
on:click={() => {
formSettings.backups.cron = "0 0 * * *";
}}
>
<span class="txt">Every day at 00:00h</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
formSettings.backups.cron = "0 0 * * 0";
}}
>
<span class="txt">Every sunday at 00:00h</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
formSettings.backups.cron = "0 0 * * 1,3";
}}
>
<span class="txt">Every Mon and Wed at 00:00h</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
formSettings.backups.cron = "0 0 1 * *";
}}
>
<span class="txt">
Every first day of the month at 00:00h
</span>
</button>
</Toggler>
</button>
</div>
<div class="help-block">
Only numeric list, steps or ranges are supported.
</div>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field required"
name="backups.cronMaxKeep"
let:uniqueId
>
<label for={uniqueId}>Max @auto backups to keep</label>
<input
type="number"
id={uniqueId}
min="1"
bind:value={formSettings.backups.cronMaxKeep}
/>
</Field>
</div>
</div>
</div>
{/if}
<div class="clearfix m-b-base" />
<S3Fields
toggleLabel="Store backups in S3 storage"
testFilesystem="backups"
configKey="backups.s3"
originalConfig={originalFormSettings.backups?.s3}
bind:config={formSettings.backups.s3}
bind:isTesting
bind:testError
/>
<div class="flex">
<div class="flex-fill" />
{#if formSettings.backups?.s3?.enabled && !hasChanges && !isSaving}
{#if isTesting}
<span class="loader loader-sm" />
{:else if testError}
<div
class="label label-sm label-warning entrance-right"
use:tooltip={testError.data?.message}
>
<i class="ri-error-warning-line txt-warning" />
<span class="txt">Failed to establish S3 connection</span>
</div>
{:else}
<div class="label label-sm label-success entrance-right">
<i class="ri-checkbox-circle-line txt-success" />
<span class="txt">S3 connected successfully</span>
</div>
{/if}
{/if}
{#if hasChanges}
<button
type="submit"
class="btn btn-hint btn-transparent"
disabled={!hasChanges || isSaving}
on:click={() => reset()}
>
<span class="txt">Reset</span>
</button>
{/if}
<button
type="submit"
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!hasChanges || isSaving}
on:click={() => save()}
>
<span class="txt">Save changes</span>
</button>
</div>
</form>
{/if}
</div>
</div>
</PageWrapper>
@@ -33,7 +33,7 @@
delete collection.updated;
}
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoadingCollections = false;
@@ -93,7 +93,7 @@
delete collection.updated;
}
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoadingOldCollections = false;
+2 -2
View File
@@ -45,7 +45,7 @@
const settings = (await ApiClient.settings.getAll()) || {};
init(settings);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -64,7 +64,7 @@
setErrors({});
addSuccessToast("Successfully saved mail settings.");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSaving = false;
+58 -169
View File
@@ -1,5 +1,4 @@
<script>
import { onMount } from "svelte";
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
@@ -8,9 +7,8 @@
import { removeAllToasts, addWarningToast, addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Field from "@/components/base/Field.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import S3Fields from "@/components/settings/S3Fields.svelte";
$pageTitle = "Files storage";
@@ -21,8 +19,7 @@
let isLoading = false;
let isSaving = false;
let isTesting = false;
let testS3Error = null;
let testS3TimeoutId = null;
let testError = null;
$: initialHash = JSON.stringify(originalFormSettings);
@@ -37,7 +34,7 @@
const settings = (await ApiClient.settings.getAll()) || {};
init(settings);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -59,13 +56,13 @@
removeAllToasts();
if (testS3Error) {
if (testError) {
addWarningToast("Successfully saved but failed to establish S3 connection.");
} else {
addSuccessToast("Successfully saved files storage settings.");
}
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSaving = false;
@@ -75,49 +72,13 @@
formSettings = {
s3: settings?.s3 || {},
};
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
await testS3();
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
}
async function reset() {
formSettings = JSON.parse(JSON.stringify(originalFormSettings || {}));
await testS3();
}
async function testS3() {
testS3Error = null;
if (!formSettings.s3.enabled) {
return; // nothing to test
}
// auto cancel the test request after 30sec
ApiClient.cancelRequest(testRequestKey);
clearTimeout(testS3TimeoutId);
testS3TimeoutId = setTimeout(() => {
ApiClient.cancelRequest(testRequestKey);
addErrorToast("S3 test connection timeout.");
}, 30000);
isTesting = true;
try {
await ApiClient.settings.testS3({ $cancelKey: testRequestKey });
} catch (err) {
testS3Error = err;
}
isTesting = false;
clearTimeout(testS3TimeoutId);
}
onMount(() => {
return () => {
clearTimeout(testS3TimeoutId);
};
});
</script>
<SettingsSidebar />
@@ -142,129 +103,57 @@
{#if isLoading}
<div class="loader" />
{:else}
<Field class="form-field form-field-toggle" let:uniqueId>
<input type="checkbox" id={uniqueId} required bind:checked={formSettings.s3.enabled} />
<label for={uniqueId}>Use S3 storage</label>
</Field>
{#if originalFormSettings.s3?.enabled != formSettings.s3.enabled}
<div transition:slide|local={{ duration: 150 }}>
<div class="alert alert-warning m-0">
<div class="icon">
<i class="ri-error-warning-line" />
</div>
<div class="content">
If you have existing uploaded files, you'll have to migrate them manually from
the
<strong>
{originalFormSettings.s3?.enabled ? "S3 storage" : "local file system"}
</strong>
to the
<strong>{formSettings.s3.enabled ? "S3 storage" : "local file system"}</strong
>.
<br />
There are numerous command line tools that can help you, such as:
<a
href="https://github.com/rclone/rclone"
target="_blank"
rel="noopener noreferrer"
class="txt-bold"
>
rclone
</a>,
<a
href="https://github.com/peak/s5cmd"
target="_blank"
rel="noopener noreferrer"
class="txt-bold"
>
s5cmd
</a>, etc.
<S3Fields
toggleLabel="Use S3 storage"
originalConfig={originalFormSettings.s3}
bind:config={formSettings.s3}
bind:isTesting
bind:testError
>
{#if originalFormSettings.s3?.enabled != formSettings.s3.enabled}
<div transition:slide|local={{ duration: 150 }}>
<div class="alert alert-warning m-0">
<div class="icon">
<i class="ri-error-warning-line" />
</div>
<div class="content">
If you have existing uploaded files, you'll have to migrate them manually
from the
<strong>
{originalFormSettings.s3?.enabled
? "S3 storage"
: "local file system"}
</strong>
to the
<strong
>{formSettings.s3.enabled
? "S3 storage"
: "local file system"}</strong
>.
<br />
There are numerous command line tools that can help you, such as:
<a
href="https://github.com/rclone/rclone"
target="_blank"
rel="noopener noreferrer"
class="txt-bold"
>
rclone
</a>,
<a
href="https://github.com/peak/s5cmd"
target="_blank"
rel="noopener noreferrer"
class="txt-bold"
>
s5cmd
</a>, etc.
</div>
</div>
<div class="clearfix m-t-base" />
</div>
<div class="clearfix m-t-base" />
</div>
{/if}
{#if formSettings.s3.enabled}
<div class="grid" transition:slide|local={{ duration: 150 }}>
<div class="col-lg-6">
<Field class="form-field required" name="s3.endpoint" let:uniqueId>
<label for={uniqueId}>Endpoint</label>
<input
type="text"
id={uniqueId}
required
bind:value={formSettings.s3.endpoint}
/>
</Field>
</div>
<div class="col-lg-3">
<Field class="form-field required" name="s3.bucket" let:uniqueId>
<label for={uniqueId}>Bucket</label>
<input
type="text"
id={uniqueId}
required
bind:value={formSettings.s3.bucket}
/>
</Field>
</div>
<div class="col-lg-3">
<Field class="form-field required" name="s3.region" let:uniqueId>
<label for={uniqueId}>Region</label>
<input
type="text"
id={uniqueId}
required
bind:value={formSettings.s3.region}
/>
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="s3.accessKey" let:uniqueId>
<label for={uniqueId}>Access key</label>
<input
type="text"
id={uniqueId}
required
bind:value={formSettings.s3.accessKey}
/>
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="s3.secret" let:uniqueId>
<label for={uniqueId}>Secret</label>
<RedactedPasswordInput
id={uniqueId}
required
bind:value={formSettings.s3.secret}
/>
</Field>
</div>
<div class="col-lg-12">
<Field class="form-field" name="s3.forcePathStyle" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={formSettings.s3.forcePathStyle}
/>
<label for={uniqueId}>
<span class="txt">Force path-style addressing</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".',
position: "top",
}}
/>
</label>
</Field>
</div>
<!-- margin helper -->
<div class="col-lg-12" />
</div>
{/if}
{/if}
</S3Fields>
<div class="flex">
<div class="flex-fill" />
@@ -272,10 +161,10 @@
{#if formSettings.s3?.enabled && !hasChanges && !isSaving}
{#if isTesting}
<span class="loader loader-sm" />
{:else if testS3Error}
{:else if testError}
<div
class="label label-sm label-warning entrance-right"
use:tooltip={testS3Error.data?.message}
use:tooltip={testError.data?.message}
>
<i class="ri-error-warning-line txt-warning" />
<span class="txt">Failed to establish S3 connection</span>
@@ -295,7 +184,7 @@
disabled={isSaving}
on:click={() => reset()}
>
<span class="txt">Cancel</span>
<span class="txt">Reset</span>
</button>
{/if}
@@ -41,7 +41,7 @@
const result = (await ApiClient.settings.getAll()) || {};
initSettings(result);
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isLoading = false;
@@ -59,7 +59,7 @@
initSettings(result);
addSuccessToast("Successfully saved tokens options.");
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSaving = false;
+143
View File
@@ -0,0 +1,143 @@
<script>
import { onMount } from "svelte";
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import { removeError } from "@/stores/errors";
import Field from "@/components/base/Field.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
const testRequestKey = "s3_test_request";
export let originalConfig = {};
export let config = {};
export let configKey = "s3";
export let toggleLabel = "Enable S3";
export let testFilesystem = "storage"; // storage or backups
export let testError = null;
export let isTesting = false;
let testTimeoutId = null;
let testDebounceId = null;
$: if (originalConfig?.enabled) {
testConnectionWithDebounce(100);
}
// clear s3 errors on disable
$: if (!config.enabled) {
removeError(configKey);
}
function testConnectionWithDebounce(timeout) {
isTesting = true;
clearTimeout(testDebounceId);
testDebounceId = setTimeout(() => {
testConnection();
}, timeout);
}
async function testConnection() {
testError = null;
if (!config.enabled) {
isTesting = false;
return testError; // nothing to test
}
// auto cancel the test request after 30sec
ApiClient.cancelRequest(testRequestKey);
clearTimeout(testTimeoutId);
testTimeoutId = setTimeout(() => {
ApiClient.cancelRequest(testRequestKey);
testError = new Error("S3 test connection timeout.");
isTesting = false;
}, 30000);
isTesting = true;
let err;
try {
await ApiClient.settings.testS3(testFilesystem, {
$cancelKey: testRequestKey,
});
} catch (e) {
err = e;
}
if (!err?.isAbort) {
testError = err;
isTesting = false;
clearTimeout(testTimeoutId);
}
return testError;
}
onMount(() => {
return () => {
clearTimeout(testTimeoutId);
clearTimeout(testDebounceId);
};
});
</script>
<Field class="form-field form-field-toggle" let:uniqueId>
<input type="checkbox" id={uniqueId} required bind:checked={config.enabled} />
<label for={uniqueId}>{toggleLabel}</label>
</Field>
<slot {isTesting} {testError} enabled={config.enabled} />
{#if config.enabled}
<div class="grid" transition:slide|local={{ duration: 150 }}>
<div class="col-lg-6">
<Field class="form-field required" name="{configKey}.endpoint" let:uniqueId>
<label for={uniqueId}>Endpoint</label>
<input type="text" id={uniqueId} required bind:value={config.endpoint} />
</Field>
</div>
<div class="col-lg-3">
<Field class="form-field required" name="{configKey}.bucket" let:uniqueId>
<label for={uniqueId}>Bucket</label>
<input type="text" id={uniqueId} required bind:value={config.bucket} />
</Field>
</div>
<div class="col-lg-3">
<Field class="form-field required" name="{configKey}.region" let:uniqueId>
<label for={uniqueId}>Region</label>
<input type="text" id={uniqueId} required bind:value={config.region} />
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="{configKey}.accessKey" let:uniqueId>
<label for={uniqueId}>Access key</label>
<input type="text" id={uniqueId} required bind:value={config.accessKey} />
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="{configKey}.secret" let:uniqueId>
<label for={uniqueId}>Secret</label>
<RedactedPasswordInput id={uniqueId} required bind:value={config.secret} />
</Field>
</div>
<div class="col-lg-12">
<Field class="form-field" name="{configKey}.forcePathStyle" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={config.forcePathStyle} />
<label for={uniqueId}>
<span class="txt">Force path-style addressing</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".',
position: "top",
}}
/>
</label>
</Field>
</div>
<!-- margin helper -->
<div class="col-lg-12" />
</div>
{/if}
@@ -28,6 +28,15 @@
<i class="ri-archive-drawer-line" />
<span class="txt">Files storage</span>
</a>
<a
href="/settings/backups"
class="sidebar-list-item"
use:active={{ path: "/settings/backups/?.*" }}
use:link
>
<i class="ri-archive-line" />
<span class="txt">Backups</span>
</a>
<div class="sidebar-title">
<span class="txt">Sync</span>
@@ -60,7 +60,7 @@
panel?.hide();
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isSubmitting = false;
+7
View File
@@ -13,6 +13,7 @@ import PageAuthProviders from "@/components/settings/PageAuthProviders.svelt
import PageTokenOptions from "@/components/settings/PageTokenOptions.svelte";
import PageExportCollections from "@/components/settings/PageExportCollections.svelte";
import PageImportCollections from "@/components/settings/PageImportCollections.svelte";
import PageBackups from "@/components/settings/PageBackups.svelte";
const baseConditions = [
async (details) => {
@@ -105,6 +106,12 @@ const routes = {
userData: { showAppSidebar: true },
}),
"/settings/backups": wrap({
component: PageBackups,
conditions: baseConditions.concat([(_) => ApiClient.authStore.isValid]),
userData: { showAppSidebar: true },
}),
// ---------------------------------------------------------------
// Records email confirmation actions
// ---------------------------------------------------------------
+10 -5
View File
@@ -113,11 +113,16 @@
@include shadowize();
}
}
body:not(.overlay-active) {
.app-sidebar ~ .app-body .toasts-wrapper {
left: var(--appSidebarWidth);
@media screen and (min-width: 980px) {
body:not(.overlay-active):has(.app-sidebar) {
.toasts-wrapper {
left: var(--appSidebarWidth);
}
}
.app-sidebar ~ .app-body .page-sidebar ~ .toasts-wrapper {
left: calc(var(--appSidebarWidth) + var(--pageSidebarWidth));
body:not(.overlay-active):has(.page-sidebar) {
.toasts-wrapper {
left: calc(var(--appSidebarWidth) + var(--pageSidebarWidth));
}
}
}
+23 -4
View File
@@ -511,9 +511,10 @@ a,
.thumb {
--thumbSize: 40px;
flex-shrink: 0;
position: relative;
display: inline-flex;
vertical-align: top;
position: relative;
flex-shrink: 0;
align-items: center;
justify-content: center;
line-height: 1;
@@ -772,7 +773,7 @@ a.thumb:not(.thumb-active) {
align-items: center;
margin: -1px -5px -1px 0;
&.nonintrusive {
@include hide();
opacity: 0;
transform: translateX(5px);
transition: transform var(--baseAnimationSpeed),
opacity var(--baseAnimationSpeed),
@@ -780,9 +781,12 @@ a.thumb:not(.thumb-active) {
}
}
&:hover,
&:focus-visible,
&:focus-within,
&:active {
background: var(--bodyColor);
.actions.nonintrusive {
@include show();
opacity: 1;
transform: translateX(0);
}
}
@@ -807,13 +811,28 @@ a.thumb:not(.thumb-active) {
}
}
.list-item-placeholder {
color: var(--txtHintColor);
}
.list-item-btn {
padding: 5px;
min-height: auto;
}
.list-item-placeholder,
.list-item-btn {
&:hover,
&:focus-visible,
&:focus-within,
&:active {
background: none;
}
}
&.list-compact {
.list-item {
gap: 10px;
min-height: 40px;
}
}
+8
View File
@@ -15,6 +15,7 @@ button {
position: relative;
z-index: 1;
display: inline-flex;
vertical-align: top;
align-items: center;
justify-content: center;
outline: 0;
@@ -238,6 +239,8 @@ button {
}
}
&.btn-xs {
padding-left: 7px;
padding-right: 7px;
min-width: var(--xsBtnHeight);
min-height: var(--xsBtnHeight);
}
@@ -1108,6 +1111,11 @@ select {
transition: background var(--baseAnimationSpeed);
.list-item {
border-top: 1px solid var(--baseAlt2Color);
&:hover,
&:focus-visible,
&:active {
background: none;
}
&.selected {
background: var(--baseAlt2Color);
}
+1 -1
View File
@@ -26,7 +26,7 @@
--warningColor: #ff944d;
--warningAltColor: #ffd4b8;
--overlayColor: rgba(53, 71, 104, 0.25);
--overlayColor: rgba(53, 71, 104, 0.28);
--tooltipColor: rgba(0, 0, 0, 0.85);
--shadowColor: rgba(0, 0, 0, 0.06);
+1 -1
View File
@@ -75,7 +75,7 @@ export async function loadCollections(activeId = null) {
refreshProtectedFilesCollectionsCache();
} catch (err) {
ApiClient.errorResponseHandler(err);
ApiClient.error(err);
}
isCollectionsLoading.set(false);
+2 -2
View File
@@ -31,7 +31,7 @@ PocketBase.prototype.logout = function(redirect = true) {
* @param {Boolean} notify Whether to add a toast notification.
* @param {String} defaultMsg Default toast notification message if the error doesn't have one.
*/
PocketBase.prototype.errorResponseHandler = function(err, notify = true, defaultMsg = "") {
PocketBase.prototype.error = function(err, notify = true, defaultMsg = "") {
if (!err || !(err instanceof Error) || err.isAbort) {
return;
}
@@ -88,7 +88,7 @@ PocketBase.prototype.getAdminFileToken = async function(collectionId = "") {
let token = localStorage.getItem(adminFileTokenKey) || "";
// request a new token only if the previous one is missing or will expire soon
if (!token || isTokenExpired(token, 15)) {
if (!token || isTokenExpired(token, 10)) {
// remove previously stored token (if any)
token && localStorage.removeItem(adminFileTokenKey);
+80 -56
View File
@@ -285,7 +285,7 @@ export default class CommonHelper {
const result = JSON.parse(JSON.stringify(obj || {}));
for (let prop in result) {
if (typeof result[prop] === 'object' && result[prop] !== null) {
if (typeof result[prop] === "object" && result[prop] !== null) {
result[prop] = CommonHelper.filterRedactedProps(result[prop], mask)
} else if (result[prop] === mask) {
delete result[prop];
@@ -313,7 +313,7 @@ export default class CommonHelper {
*/
static getNestedVal(data, path, defaultVal = null, delimiter = ".") {
let result = data || {};
let parts = (path || '').split(delimiter);
let parts = (path || "").split(delimiter);
for (const part of parts) {
if (
@@ -343,7 +343,7 @@ export default class CommonHelper {
* @param {String} delimiter
*/
static setByPath(data, path, newValue, delimiter = ".") {
if (data === null || typeof data !== 'object') {
if (data === null || typeof data !== "object") {
console.warn("setByPath: data not an object or array.");
return
}
@@ -382,7 +382,7 @@ export default class CommonHelper {
*/
static deleteByPath(data, path, delimiter = ".") {
let result = data || {};
let parts = (path || '').split(delimiter);
let parts = (path || "").split(delimiter);
let lastPart = parts.pop();
for (const part of parts) {
@@ -533,7 +533,7 @@ export default class CommonHelper {
for (let key in obj) {
let value = obj[key];
if (typeof value === 'string') {
if (typeof value === "string") {
value = CommonHelper.truncate(value, 150, true);
}
@@ -551,47 +551,47 @@ export default class CommonHelper {
* @param {Array} [preserved]
* @return {String}
*/
static slugify(str, delimiter = '_', preserved = ['.', '=', '-']) {
if (str === '') {
return '';
static slugify(str, delimiter = "_", preserved = [".", "=", "-"]) {
if (str === "") {
return "";
}
// special characters
const specialCharsMap = {
'a': /а|à|á|å|â/gi,
'b': /б/gi,
'c': /ц|ç/gi,
'd': /д/gi,
'e': /е|è|é|ê|ẽ|ë/gi,
'f': /ф/gi,
'g': /г/gi,
'h': /х/gi,
'i': /й|и|ì|í|î/gi,
'j': /ж/gi,
'k': /к/gi,
'l': /л/gi,
'm': /м/gi,
'n': /н|ñ/gi,
'o': /о|ò|ó|ô|ø/gi,
'p': /п/gi,
'q': /я/gi,
'r': /р/gi,
's': /с/gi,
't': /т/gi,
'u': /ю|ù|ú|ů|û/gi,
'v': /в/gi,
'w': /в/gi,
'x': /ь/gi,
'y': /ъ/gi,
'z': /з/gi,
'ae': /ä|æ/gi,
'oe': /ö/gi,
'ue': /ü/gi,
'Ae': /Ä/gi,
'Ue': /Ü/gi,
'Oe': /Ö/gi,
'ss': /ß/gi,
'and': /&/gi
"a": /а|à|á|å|â/gi,
"b": /б/gi,
"c": /ц|ç/gi,
"d": /д/gi,
"e": /е|è|é|ê|ẽ|ë/gi,
"f": /ф/gi,
"g": /г/gi,
"h": /х/gi,
"i": /й|и|ì|í|î/gi,
"j": /ж/gi,
"k": /к/gi,
"l": /л/gi,
"m": /м/gi,
"n": /н|ñ/gi,
"o": /о|ò|ó|ô|ø/gi,
"p": /п/gi,
"q": /я/gi,
"r": /р/gi,
"s": /с/gi,
"t": /т/gi,
"u": /ю|ù|ú|ů|û/gi,
"v": /в/gi,
"w": /в/gi,
"x": /ь/gi,
"y": /ъ/gi,
"z": /з/gi,
"ae": /ä|æ/gi,
"oe": /ö/gi,
"ue": /ü/gi,
"Ae": /Ä/gi,
"Ue": /Ü/gi,
"Oe": /Ö/gi,
"ss": /ß/gi,
"and": /&/gi
};
// replace special characters
@@ -600,8 +600,8 @@ export default class CommonHelper {
}
return str
.replace(new RegExp('[' + preserved.join('') + ']', 'g'), ' ') // replace preserved characters with spaces
.replace(/[^\w\ ]/gi, '') // replaces all non-alphanumeric with empty string
.replace(new RegExp('[' + preserved.join("") + ']', 'g'), ' ') // replace preserved characters with spaces
.replace(/[^\w\ ]/gi, "") // replaces all non-alphanumeric with empty string
.replace(/\s+/g, delimiter); // collapse whitespaces and replace with `delimiter`
}
@@ -666,7 +666,7 @@ export default class CommonHelper {
* @return {String}
*/
static getInitials(str) {
str = (str || '').split('@')[0].trim();
str = (str || "").split("@")[0].trim();
if (str.length <= 2) {
return str.toUpperCase();
@@ -681,6 +681,18 @@ export default class CommonHelper {
return str[0].toUpperCase();
}
/**
* Returns a human readable file size string from size in bytes.
*
* @param {Number} size s
* @return {String}
*/
static formattedFileSize(size) {
const i = size ? Math.floor(Math.log(size) / Math.log(1024)) : 0;
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "KB", "MB", "GB", "TB"][i];
}
/**
* Returns a DateTime instance from a date object/string.
*
@@ -688,7 +700,7 @@ export default class CommonHelper {
* @return {DateTime}
*/
static getDateTime(date) {
if (typeof date === 'string') {
if (typeof date === "string") {
const formats = {
19: "yyyy-MM-dd HH:mm:ss",
23: "yyyy-MM-dd HH:mm:ss.SSS",
@@ -696,7 +708,7 @@ export default class CommonHelper {
24: "yyyy-MM-dd HH:mm:ss.SSS'Z'",
}
const format = formats[date.length] || formats[19];
return DateTime.fromFormat(date, format, { zone: 'UTC' });
return DateTime.fromFormat(date, format, { zone: "UTC" });
}
return DateTime.fromJSDate(date);
@@ -709,7 +721,7 @@ export default class CommonHelper {
* @param {String} [format] The result format (see https://moment.github.io/luxon/#/parsing?id=table-of-tokens)
* @return {String}
*/
static formatToUTCDate(date, format = 'yyyy-MM-dd HH:mm:ss') {
static formatToUTCDate(date, format = "yyyy-MM-dd HH:mm:ss") {
return CommonHelper.getDateTime(date).toUTC().toFormat(format);
}
@@ -720,7 +732,7 @@ export default class CommonHelper {
* @param {String} [format] The result format (see https://moment.github.io/luxon/#/parsing?id=table-of-tokens)
* @return {String}
*/
static formatToLocalDate(date, format = 'yyyy-MM-dd HH:mm:ss') {
static formatToLocalDate(date, format = "yyyy-MM-dd HH:mm:ss") {
return CommonHelper.getDateTime(date).toLocal().toFormat(format);
}
@@ -742,20 +754,32 @@ export default class CommonHelper {
})
}
/**
* Forces the browser to start downloading the specified url.
*
* @param {String} url The url of the file to download.
* @param {String} name The result file name.
*/
static download(url, name) {
const tempLink = document.createElement("a");
tempLink.setAttribute("href", url);
tempLink.setAttribute("download", name);
tempLink.click();
tempLink.remove();
}
/**
* Downloads a json file created from the provide object.
*
* @param {mixed} obj The JS object to download.
* @param {String} name The result file name.
* @param {String} name The result file name.
*/
static downloadJson(obj, name) {
const encodedObj = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(obj, null, 2));
const tempLink = document.createElement('a');
tempLink.setAttribute("href", encodedObj);
tempLink.setAttribute("download", name + ".json");
tempLink.click();
tempLink.remove();
name = name.endsWith(".json") ? name : (name + ".json");
download(encodedObj, name)
}
/**
@@ -765,7 +789,7 @@ export default class CommonHelper {
* @return {Object}
*/
static getJWTPayload(jwt) {
const raw = (jwt || '').split(".")[1] || '';
const raw = (jwt || "").split(".")[1] || "";
if (raw === "") {
return {};
}