mirror of
https://github.com/imputnet/cobalt.git
synced 2026-10-25 03:22:19 +00:00
web: add transcript download controls
This commit is contained in:
parent
d63deb94ac
commit
d523748431
19 changed files with 170 additions and 20 deletions
|
|
@ -7,6 +7,7 @@
|
|||
"download": "download",
|
||||
"share": "share",
|
||||
"copy": "copy",
|
||||
"copy.text": "copy text",
|
||||
"copy.section": "copy the section link",
|
||||
"copied": "copied",
|
||||
"import": "import",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"saving.title": "choose how to save",
|
||||
"saving.blocked": "cobalt tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for cobalt to prevent this from happening next time.",
|
||||
"saving.timeout": "cobalt tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.",
|
||||
"saving.copy_failed": "your browser wouldn't let cobalt copy the transcript. check clipboard permissions and try again.",
|
||||
|
||||
"safety.title": "important safety notice",
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"service.unsupported": "this service is not supported yet. have you pasted the right link?",
|
||||
"service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
|
||||
"service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!",
|
||||
"service.captions_not_supported": "standalone transcripts are only available for youtube links right now.",
|
||||
|
||||
"link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?",
|
||||
"link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",
|
||||
|
|
@ -59,5 +60,7 @@
|
|||
"youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
"youtube.disabled_main_instance": "youtube downloading is disabled on the main instance due to restrictions from youtube's side and infinite maintenance cost at scale.\n\nwe apologize for the inconvenience and encourage you to host your own API instance for this.",
|
||||
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
|
||||
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
|
||||
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
"youtube.captions_unavailable": "this video doesn't have any captions i can download.",
|
||||
"youtube.caption_language_unavailable": "this video doesn't have captions in the selected language. available languages: {{ languages }}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"auto": "auto",
|
||||
"audio": "audio",
|
||||
"mute": "mute",
|
||||
"captions": "transcript",
|
||||
"input.placeholder": "paste the link here",
|
||||
"terms.note.agreement": "by continuing, you agree to",
|
||||
"terms.note.link": "terms and ethics of use",
|
||||
|
|
|
|||
|
|
@ -65,8 +65,19 @@
|
|||
|
||||
"subtitles": "subtitles",
|
||||
"subtitles.title": "preferred subtitle language",
|
||||
"subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available.\n\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.",
|
||||
"subtitles.description": "cobalt will add subtitles to downloaded media in the preferred language when they're available.",
|
||||
"subtitles.none": "none",
|
||||
"captions": "transcripts",
|
||||
"captions.format": "transcript file format",
|
||||
"captions.format.description": "plain text and markdown omit timestamps. webvtt and subrip keep caption timing.",
|
||||
"captions.format.txt": "plain text (.txt)",
|
||||
"captions.format.vtt": "webvtt (.vtt)",
|
||||
"captions.format.srt": "subrip (.srt)",
|
||||
"captions.format.md": "markdown (.md)",
|
||||
"captions.method": "transcript saving method",
|
||||
"captions.method.description": "how to save transcripts from the save button. \"ask\" shows both options every time.",
|
||||
"captions.language": "preferred transcript language",
|
||||
"captions.language.description": "cobalt will pick the transcript in this language when the video has multiple caption tracks. otherwise it falls back to the video's default track.",
|
||||
|
||||
"audio.youtube.better_audio": "youtube audio quality",
|
||||
"audio.youtube.better_audio.title": "prefer better quality",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { hapticConfirm } from "$lib/haptics";
|
||||
import {
|
||||
copyURL,
|
||||
copyTextFromURL,
|
||||
openURL,
|
||||
shareURL,
|
||||
openFile,
|
||||
|
|
@ -33,10 +34,12 @@
|
|||
export let url: string = "";
|
||||
export let file: File | undefined = undefined;
|
||||
export let urlType: CobaltFileUrlType | undefined = undefined;
|
||||
export let copyTextURL = "";
|
||||
|
||||
let close: () => void;
|
||||
|
||||
let copied = false;
|
||||
let copyFailed = false;
|
||||
|
||||
$: if (copied) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -103,15 +106,24 @@
|
|||
elevated
|
||||
click={async () => {
|
||||
if (!copied) {
|
||||
copyURL(url);
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
try {
|
||||
if (copyTextURL) {
|
||||
await copyTextFromURL(copyTextURL);
|
||||
} else {
|
||||
await copyURL(url);
|
||||
}
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
copyFailed = false;
|
||||
} catch {
|
||||
copyFailed = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
ariaLabel={copied ? $t("button.copied") : ""}
|
||||
>
|
||||
<CopyIcon check={copied} />
|
||||
{$t("button.copy")}
|
||||
<CopyIcon check={copied} regularIcon={!!copyTextURL} />
|
||||
{$t(copyTextURL ? "button.copy.text" : "button.copy")}
|
||||
</VerticalActionButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -125,6 +137,12 @@
|
|||
{bodyText}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if copyFailed}
|
||||
<div class="body-text">
|
||||
{$t("dialog.saving.copy_failed")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogButtons
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
import IconMusic from "$components/icons/Music.svelte";
|
||||
import IconSparkles from "$components/icons/Sparkles.svelte";
|
||||
import IconClipboard from "$components/icons/Clipboard.svelte";
|
||||
import IconFileText from "@tabler/icons-svelte/IconFileText.svelte";
|
||||
|
||||
let linkInput: Optional<HTMLInputElement>;
|
||||
|
||||
|
|
@ -223,6 +224,14 @@
|
|||
<IconMute />
|
||||
{$t("save.mute")}
|
||||
</SettingsButton>
|
||||
<SettingsButton
|
||||
settingContext="save"
|
||||
settingId="downloadMode"
|
||||
settingValue="captions"
|
||||
>
|
||||
<IconFileText />
|
||||
{$t("save.captions")}
|
||||
</SettingsButton>
|
||||
</Switcher>
|
||||
|
||||
<ActionButton id="paste" click={pasteClipboard}>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
|
||||
alwaysProxy: getSetting("save", "alwaysProxy"),
|
||||
downloadMode: getSetting("save", "downloadMode"),
|
||||
captionFormat: getSetting("save", "captionFormat"),
|
||||
|
||||
subtitleLang: getSetting("save", "subtitleLang"),
|
||||
filenameStyle: getSetting("save", "filenameStyle"),
|
||||
|
|
@ -69,6 +70,13 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
convertGif: getSetting("save", "convertGif"),
|
||||
}
|
||||
|
||||
if (selectedRequest.downloadMode === "captions") {
|
||||
const captionLang = getSetting("save", "captionLang");
|
||||
if (captionLang && captionLang !== "none") {
|
||||
selectedRequest.captionLanguage = captionLang;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await API.request(selectedRequest);
|
||||
|
||||
if (!response) {
|
||||
|
|
@ -103,6 +111,12 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
|
||||
return downloadFile({
|
||||
url: response.url,
|
||||
method: selectedRequest.downloadMode === "captions"
|
||||
? getSetting("save", "transcriptMethod")
|
||||
: undefined,
|
||||
copyTextURL: selectedRequest.downloadMode === "captions"
|
||||
? response.text || response.url
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
downloadButtonState.set("error");
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import { createDialog } from "$lib/state/dialogs";
|
|||
|
||||
import type { DialogInfo } from "$lib/types/dialog";
|
||||
import type { CobaltFileUrlType } from "$lib/types/api";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
type DownloadFileParams = {
|
||||
url?: string,
|
||||
file?: File,
|
||||
urlType?: CobaltFileUrlType,
|
||||
copyTextURL?: string,
|
||||
method?: CobaltSettings['save']['transcriptMethod'],
|
||||
}
|
||||
|
||||
type SavingDialogParams = {
|
||||
|
|
@ -20,15 +23,17 @@ type SavingDialogParams = {
|
|||
file?: File,
|
||||
body?: string,
|
||||
urlType?: CobaltFileUrlType,
|
||||
copyTextURL?: string,
|
||||
}
|
||||
|
||||
const openSavingDialog = ({ url, file, body, urlType }: SavingDialogParams) => {
|
||||
const openSavingDialog = ({ url, file, body, urlType, copyTextURL }: SavingDialogParams) => {
|
||||
const dialogData: DialogInfo = {
|
||||
type: "saving",
|
||||
id: "saving",
|
||||
file,
|
||||
url,
|
||||
urlType,
|
||||
copyTextURL,
|
||||
}
|
||||
if (body) dialogData.bodyText = body;
|
||||
|
||||
|
|
@ -75,13 +80,19 @@ export const copyURL = async (url: string) => {
|
|||
return await navigator?.clipboard?.writeText(url);
|
||||
}
|
||||
|
||||
export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
|
||||
export const copyTextFromURL = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("couldn't fetch transcript");
|
||||
return await navigator?.clipboard?.writeText(await response.text());
|
||||
}
|
||||
|
||||
export const downloadFile = async ({ url, file, urlType, copyTextURL, method }: DownloadFileParams) => {
|
||||
if (!url && !file) throw new Error("attempted to download void");
|
||||
|
||||
const pref = get(settings).save.savingMethod;
|
||||
const pref = method ?? get(settings).save.savingMethod;
|
||||
|
||||
if (pref === "ask") {
|
||||
return openSavingDialog({ url, file, urlType });
|
||||
return openSavingDialog({ url, file, urlType, copyTextURL });
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -100,7 +111,8 @@ export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
|
|||
url,
|
||||
file,
|
||||
body: get(t)("dialog.saving.timeout"),
|
||||
urlType
|
||||
urlType,
|
||||
copyTextURL,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -134,10 +146,12 @@ export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
|
|||
&& !(device.is.iOS && urlType === "redirect")) {
|
||||
return openURL(url);
|
||||
} else if (pref === "copy" && !file) {
|
||||
return copyURL(url);
|
||||
return copyTextURL
|
||||
? await copyTextFromURL(copyTextURL)
|
||||
: await copyURL(url);
|
||||
}
|
||||
}
|
||||
} catch { /* catch & ignore */ }
|
||||
|
||||
return openSavingDialog({ url, file, urlType });
|
||||
return openSavingDialog({ url, file, urlType, copyTextURL });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const config: Config<{
|
|||
formats?: string;
|
||||
limit?: number;
|
||||
service?: string;
|
||||
languages?: string[];
|
||||
}> = {
|
||||
fallbackLocale: defaultLocale,
|
||||
translations: Object.keys(parsedLocfiles).reduce((obj, lang) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defaultLocale } from "$lib/i18n/translations";
|
|||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
const defaultSettings: CobaltSettings = {
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
advanced: {
|
||||
debug: false,
|
||||
useWebCodecs: false,
|
||||
|
|
@ -28,6 +28,9 @@ const defaultSettings: CobaltSettings = {
|
|||
audioFormat: "mp3",
|
||||
disableMetadata: false,
|
||||
downloadMode: "auto",
|
||||
captionFormat: "txt",
|
||||
captionLang: "none",
|
||||
transcriptMethod: "download",
|
||||
filenameStyle: "basic",
|
||||
savingMethod: "download",
|
||||
allowH265: false,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
CobaltSettingsV4,
|
||||
CobaltSettingsV5,
|
||||
CobaltSettingsV6,
|
||||
CobaltSettingsV7,
|
||||
} from "$lib/types/settings";
|
||||
import { getBrowserLanguage } from "$lib/settings/audio-sub-language";
|
||||
|
||||
|
|
@ -95,6 +96,12 @@ const migrations: Record<number, Migrator> = {
|
|||
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
|
||||
[7]: (settings: AllPartialSettingsWithSchema) => {
|
||||
const out = settings as RecursivePartial<CobaltSettingsV7>;
|
||||
out.schemaVersion = 7;
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
};
|
||||
|
||||
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
themeOptions,
|
||||
videoQualityOptions,
|
||||
youtubeVideoCodecOptions,
|
||||
captionFormatOptions,
|
||||
transcriptMethodOptions,
|
||||
type PartialSettings,
|
||||
} from '$lib/types/settings';
|
||||
import { youtubeDubLanguages } from '$lib/settings/audio-sub-language';
|
||||
|
|
@ -77,6 +79,8 @@ export function validateSettings(settings: PartialSettings) {
|
|||
&& validateLiterals([
|
||||
[ settings?.appearance?.theme , themeOptions ],
|
||||
[ settings?.save?.downloadMode , downloadModeOptions ],
|
||||
[ settings?.save?.captionFormat , captionFormatOptions ],
|
||||
[ settings?.save?.transcriptMethod , transcriptMethodOptions ],
|
||||
[ settings?.save?.filenameStyle , filenameStyleOptions ],
|
||||
[ settings?.save?.videoQuality , videoQualityOptions ],
|
||||
[ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type CobaltErrorResponse = {
|
|||
context?: {
|
||||
service?: string,
|
||||
limit?: number,
|
||||
languages?: string[],
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -41,6 +42,7 @@ type CobaltRedirectResponse = {
|
|||
|
||||
type CobaltTunnelResponse = {
|
||||
status: CobaltResponseType.Tunnel,
|
||||
text?: string,
|
||||
} & CobaltPartialURLResponse;
|
||||
|
||||
export const CobaltFileMetadataKeys = [
|
||||
|
|
@ -113,7 +115,7 @@ export type CobaltServerInfo = {
|
|||
// this allows for extra properties, which is not ideal,
|
||||
// but i couldn't figure out how to make a strict partial :(
|
||||
export type CobaltSaveRequestBody =
|
||||
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
|
||||
{ url: string, captionLanguage?: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
|
||||
|
||||
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
|
||||
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type SavingDialog = Dialog & {
|
|||
url?: string,
|
||||
file?: File,
|
||||
urlType?: CobaltFileUrlType,
|
||||
copyTextURL?: string,
|
||||
};
|
||||
|
||||
export type DialogInfo = SmallDialog | PickerDialog | SavingDialog;
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
|
|||
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
|
||||
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
|
||||
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
|
||||
import type { CobaltSettingsV7 } from "$lib/types/settings/v7";
|
||||
|
||||
export * from "$lib/types/settings/v2";
|
||||
export * from "$lib/types/settings/v3";
|
||||
export * from "$lib/types/settings/v4";
|
||||
export * from "$lib/types/settings/v5";
|
||||
export * from "$lib/types/settings/v6";
|
||||
export * from "$lib/types/settings/v7";
|
||||
|
||||
export type CobaltSettings = CobaltSettingsV6;
|
||||
export type CobaltSettings = CobaltSettingsV7;
|
||||
|
||||
export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
export type AnyCobaltSettings = CobaltSettingsV6 | CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
|
||||
export type PartialSettings = RecursivePartial<CobaltSettings>;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import languages from "$i18n/languages.json";
|
|||
export const themeOptions = ["auto", "light", "dark"] as const;
|
||||
export const audioBitrateOptions = ["320", "256", "128", "96", "64", "8"] as const;
|
||||
export const audioFormatOptions = ["best", "mp3", "ogg", "wav", "opus"] as const;
|
||||
export const downloadModeOptions = ["auto", "audio", "mute"] as const;
|
||||
export const downloadModeOptions = ["auto", "audio", "mute", "captions"] as const;
|
||||
export const filenameStyleOptions = ["classic", "basic", "pretty", "nerdy"] as const;
|
||||
export const videoQualityOptions = ["max", "2160", "1440", "1080", "720", "480", "360", "240", "144"] as const;
|
||||
export const youtubeVideoCodecOptions = ["h264", "av1", "vp9"] as const;
|
||||
|
|
|
|||
14
web/src/lib/types/settings/v7.ts
Normal file
14
web/src/lib/types/settings/v7.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { SubtitleLang } from "$lib/settings/audio-sub-language";
|
||||
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
|
||||
|
||||
export const captionFormatOptions = ["txt", "vtt", "srt", "md"] as const;
|
||||
export const transcriptMethodOptions = ["ask", "download", "copy"] as const;
|
||||
|
||||
export type CobaltSettingsV7 = Omit<CobaltSettingsV6, "schemaVersion" | "save"> & {
|
||||
schemaVersion: 7,
|
||||
save: CobaltSettingsV6["save"] & {
|
||||
captionFormat: typeof captionFormatOptions[number],
|
||||
captionLang: SubtitleLang,
|
||||
transcriptMethod: typeof transcriptMethodOptions[number],
|
||||
},
|
||||
};
|
||||
|
|
@ -3,7 +3,12 @@
|
|||
import { t } from "$lib/i18n/translations";
|
||||
|
||||
import { namedSubtitleLanguages } from "$lib/settings/audio-sub-language";
|
||||
import { filenameStyleOptions, savingMethodOptions } from "$lib/types/settings";
|
||||
import {
|
||||
captionFormatOptions,
|
||||
filenameStyleOptions,
|
||||
savingMethodOptions,
|
||||
transcriptMethodOptions,
|
||||
} from "$lib/types/settings";
|
||||
|
||||
import SettingsCategory from "$components/settings/SettingsCategory.svelte";
|
||||
import Switcher from "$components/buttons/Switcher.svelte";
|
||||
|
|
@ -13,6 +18,12 @@
|
|||
import SettingsDropdown from "$components/settings/SettingsDropdown.svelte";
|
||||
|
||||
const displayLangs = namedSubtitleLanguages($t);
|
||||
const displayCaptionFormats = Object.fromEntries(
|
||||
captionFormatOptions.map(format => [format, $t(`settings.captions.format.${format}`)])
|
||||
);
|
||||
const displayTranscriptMethods = Object.fromEntries(
|
||||
transcriptMethodOptions.map(method => [method, $t(`settings.saving.${method}`)])
|
||||
);
|
||||
</script>
|
||||
|
||||
<SettingsCategory sectionId="filename" title={$t("settings.metadata.filename")}>
|
||||
|
|
@ -35,6 +46,39 @@
|
|||
</div>
|
||||
</SettingsCategory>
|
||||
|
||||
<SettingsCategory
|
||||
sectionId="transcripts"
|
||||
title={$t("settings.captions")}
|
||||
>
|
||||
<SettingsDropdown
|
||||
title={$t("settings.captions.format")}
|
||||
description={$t("settings.captions.format.description")}
|
||||
items={displayCaptionFormats}
|
||||
settingContext="save"
|
||||
settingId="captionFormat"
|
||||
selectedOption={$settings.save.captionFormat}
|
||||
selectedTitle={displayCaptionFormats[$settings.save.captionFormat]}
|
||||
/>
|
||||
<SettingsDropdown
|
||||
title={$t("settings.captions.method")}
|
||||
description={$t("settings.captions.method.description")}
|
||||
items={displayTranscriptMethods}
|
||||
settingContext="save"
|
||||
settingId="transcriptMethod"
|
||||
selectedOption={$settings.save.transcriptMethod}
|
||||
selectedTitle={displayTranscriptMethods[$settings.save.transcriptMethod]}
|
||||
/>
|
||||
<SettingsDropdown
|
||||
title={$t("settings.captions.language")}
|
||||
description={$t("settings.captions.language.description")}
|
||||
items={displayLangs}
|
||||
settingContext="save"
|
||||
settingId="captionLang"
|
||||
selectedOption={$settings.save.captionLang}
|
||||
selectedTitle={displayLangs[$settings.save.captionLang]}
|
||||
/>
|
||||
</SettingsCategory>
|
||||
|
||||
<SettingsCategory sectionId="saving" title={$t("settings.saving.title")}>
|
||||
<Switcher big={true} description={$t("settings.saving.description")}>
|
||||
{#each savingMethodOptions as value}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue