Skip to content

Commit a5265ad

Browse files
committed
Implement GPT translator
#438
1 parent 0206988 commit a5265ad

20 files changed

Lines changed: 991 additions & 91 deletions

File tree

src/lib/components/contents/details/editor/translator-api-key-dialog.svelte

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<script>
2-
import { PromptDialog } from '@sveltia/ui';
2+
import { PromptDialog, Spacer } from '@sveltia/ui';
33
import DOMPurify from 'isomorphic-dompurify';
44
import { _ } from 'svelte-i18n';
55
6+
import TranslatorSelector from '$lib/components/settings/controls/translator-selector.svelte';
67
import { showContentOverlay, translatorApiKeyDialogState } from '$lib/services/contents/editor';
78
import { translator } from '$lib/services/integrations/translators';
89
import { prefs } from '$lib/services/user/prefs';
910
10-
const { serviceId, serviceLabel, developerURL, apiKeyURL, apiKeyPattern } = $derived($translator);
11+
const { serviceId, apiLabel, developerURL, apiKeyURL, apiKeyPattern } = $derived($translator);
1112
1213
$effect(() => {
1314
if (!$showContentOverlay && $translatorApiKeyDialogState.show) {
@@ -37,10 +38,12 @@
3738
$translatorApiKeyDialogState.resolve?.();
3839
}}
3940
>
41+
<TranslatorSelector />
42+
<Spacer />
4043
{@html DOMPurify.sanitize(
41-
$_('prefs.contents.translator.description', {
44+
$_('prefs.i18n.translator.description', {
4245
values: {
43-
service: serviceLabel,
46+
service: apiLabel,
4447
homeHref: `href="${developerURL}"`,
4548
apiKeyHref: `href="${apiKeyURL}"`,
4649
},
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script>
2+
import { Option, Select } from '@sveltia/ui';
3+
import { _ } from 'svelte-i18n';
4+
5+
import { allTranslationServices } from '$lib/services/integrations/translators';
6+
import { prefs } from '$lib/services/user/prefs';
7+
</script>
8+
9+
<Select
10+
aria-label={$_('prefs.i18n.default_translator.select_service')}
11+
value={$prefs.defaultTranslationService}
12+
onChange={(event) => {
13+
$prefs = { ...$prefs, defaultTranslationService: event.detail.value };
14+
}}
15+
>
16+
{#each Object.entries(allTranslationServices) as [key, { serviceLabel: label }] (key)}
17+
<Option {label} value={key} selected={key === $prefs.defaultTranslationService} />
18+
{/each}
19+
</Select>
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
1+
import { derived } from 'svelte/store';
2+
13
import AccessibilityPanel from '$lib/components/settings/panels/accessibility-panel.svelte';
24
import AdvancedPanel from '$lib/components/settings/panels/advanced-panel.svelte';
35
import AppearancePanel from '$lib/components/settings/panels/appearance-panel.svelte';
46
import ContentsPanel from '$lib/components/settings/panels/contents-panel.svelte';
7+
import I18nPanel from '$lib/components/settings/panels/i18n-panel.svelte';
58
import LanguagePanel from '$lib/components/settings/panels/language-panel.svelte';
69
import MediaPanel from '$lib/components/settings/panels/media-panel.svelte';
10+
import { siteConfig } from '$lib/services/config';
11+
12+
/**
13+
* @import { Component } from 'svelte';
14+
*/
715

8-
/** @type {{ key: string, icon: string, component: import('svelte').Component }[]} */
9-
export const panels = [
16+
/**
17+
* @type {import('svelte/store').Readable<{ key: string, icon: string, component: Component,
18+
* enabled?: boolean }[]>}
19+
*/
20+
export const panels = derived([siteConfig], ([_siteConfig]) => [
1021
{ key: 'appearance', icon: 'palette', component: AppearancePanel },
1122
{ key: 'language', icon: 'language', component: LanguagePanel },
1223
{ key: 'contents', icon: 'library_books', component: ContentsPanel },
24+
{
25+
key: 'i18n',
26+
icon: 'translate',
27+
component: I18nPanel,
28+
enabled: (_siteConfig?.i18n?.locales?.length ?? 0) > 1,
29+
},
1330
{ key: 'media', icon: 'photo_library', component: MediaPanel },
1431
{ key: 'accessibility', icon: 'accessibility_new', component: AccessibilityPanel },
1532
{ key: 'advanced', icon: 'build', component: AdvancedPanel },
16-
];
33+
]);
Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
<script>
2-
import { TextInput } from '@sveltia/ui';
3-
import DOMPurify from 'isomorphic-dompurify';
42
import { _ } from 'svelte-i18n';
53
64
import PrefSwitch from '$lib/components/settings/controls/pref-switch.svelte';
7-
import { siteConfig } from '$lib/services/config';
8-
import { allTranslationServices } from '$lib/services/integrations/translators';
9-
import { prefs } from '$lib/services/user/prefs';
105
116
/**
127
* @typedef {object} Props
@@ -39,44 +34,3 @@
3934
/>
4035
</div>
4136
</section>
42-
{#if ($siteConfig?.i18n?.locales?.length ?? 0) > 1}
43-
{#each Object.entries(allTranslationServices) as [serviceId, service] (serviceId)}
44-
{@const { serviceLabel, developerURL, apiKeyURL } = service}
45-
<section>
46-
<h4>{serviceLabel}</h4>
47-
<p>
48-
{@html DOMPurify.sanitize(
49-
$_('prefs.contents.translator.description', {
50-
values: {
51-
service: serviceLabel,
52-
homeHref: `href="${developerURL}"`,
53-
apiKeyHref: `href="${apiKeyURL}"`,
54-
},
55-
}),
56-
{ ALLOWED_TAGS: ['a'], ALLOWED_ATTR: ['href', 'target', 'rel'] },
57-
)}
58-
</p>
59-
<div role="none">
60-
{#if $prefs.apiKeys}
61-
<TextInput
62-
bind:value={$prefs.apiKeys[serviceId]}
63-
flex
64-
spellcheck="false"
65-
aria-label={$_('prefs.contents.translator.field_label', {
66-
values: { service: serviceLabel },
67-
})}
68-
onchange={() => {
69-
onChange?.({
70-
message: $_(
71-
$prefs.apiKeys?.[serviceId]
72-
? 'prefs.changes.api_key_saved'
73-
: 'prefs.changes.api_key_removed',
74-
),
75-
});
76-
}}
77-
/>
78-
{/if}
79-
</div>
80-
</section>
81-
{/each}
82-
{/if}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script>
2+
import { TextInput } from '@sveltia/ui';
3+
import DOMPurify from 'isomorphic-dompurify';
4+
import { _ } from 'svelte-i18n';
5+
6+
import TranslatorSelector from '$lib/components/settings/controls/translator-selector.svelte';
7+
import { allTranslationServices } from '$lib/services/integrations/translators';
8+
import { prefs } from '$lib/services/user/prefs';
9+
10+
/**
11+
* @typedef {object} Props
12+
* @property {(detail: { message: string }) => void} [onChange] Custom `change` event handler.
13+
*/
14+
15+
/** @type {Props} */
16+
let {
17+
/* eslint-disable prefer-const, no-unused-vars */
18+
onChange = undefined,
19+
/* eslint-enable prefer-const, no-unused-vars */
20+
} = $props();
21+
</script>
22+
23+
<section>
24+
<h4>{$_('prefs.i18n.default_translator.title')}</h4>
25+
<div role="none">
26+
<TranslatorSelector />
27+
</div>
28+
</section>
29+
30+
{#each Object.entries(allTranslationServices) as [serviceId, service] (serviceId)}
31+
{@const { serviceLabel, apiLabel, developerURL, apiKeyURL } = service}
32+
<section>
33+
<h4>{serviceLabel}</h4>
34+
<p>
35+
{@html DOMPurify.sanitize(
36+
$_('prefs.i18n.translator.description', {
37+
values: {
38+
service: apiLabel,
39+
homeHref: `href="${developerURL}"`,
40+
apiKeyHref: `href="${apiKeyURL}"`,
41+
},
42+
}),
43+
{ ALLOWED_TAGS: ['a'], ALLOWED_ATTR: ['href', 'target', 'rel'] },
44+
)}
45+
</p>
46+
<div role="none">
47+
{#if $prefs.apiKeys}
48+
<TextInput
49+
bind:value={$prefs.apiKeys[serviceId]}
50+
flex
51+
spellcheck="false"
52+
aria-label={$_('prefs.i18n.translator.field_label', {
53+
values: { service: serviceLabel },
54+
})}
55+
onchange={() => {
56+
onChange?.({
57+
message: $_(
58+
$prefs.apiKeys?.[serviceId]
59+
? 'prefs.changes.api_key_saved'
60+
: 'prefs.changes.api_key_removed',
61+
),
62+
});
63+
}}
64+
/>
65+
{/if}
66+
</div>
67+
</section>
68+
{/each}

src/lib/components/settings/settings-dialog.svelte

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import { Alert, Dialog, Icon, Tab, TabList, TabPanel, Toast } from '@sveltia/ui';
3+
import { get } from 'svelte/store';
34
import { _ } from 'svelte-i18n';
45
56
import { panels } from '$lib/components/settings';
@@ -36,22 +37,24 @@
3637
>
3738
<div role="none" class="wrapper">
3839
<TabList orientation="vertical" aria-label={$_('categories')}>
39-
{#each panels as { key, icon } (key)}
40-
<Tab
41-
label={$_(`prefs.${key}.title`)}
42-
selected={key === selectedPanel}
43-
aria-controls="prefs-tab-{key}"
44-
onSelect={() => {
45-
selectedPanel = key;
46-
}}
47-
>
48-
{#snippet startIcon()}
49-
<Icon name={icon} />
50-
{/snippet}
51-
</Tab>
40+
{#each get(panels) as { key, icon, enabled = true } (key)}
41+
{#if enabled}
42+
<Tab
43+
label={$_(`prefs.${key}.title`)}
44+
selected={key === selectedPanel}
45+
aria-controls="prefs-tab-{key}"
46+
onSelect={() => {
47+
selectedPanel = key;
48+
}}
49+
>
50+
{#snippet startIcon()}
51+
<Icon name={icon} />
52+
{/snippet}
53+
</Tab>
54+
{/if}
5255
{/each}
5356
</TabList>
54-
{#each panels as { key, component: Content } (key)}
57+
{#each get(panels) as { key, component: Content } (key)}
5558
<TabPanel id="prefs-tab-{key}">
5659
<Content
5760
onChange={(/** @type {{ message: string }} */ { message }) => {

src/lib/components/settings/settings-page.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script>
22
import { Alert, Icon, Menu, MenuItem, Spacer, Toast, Toolbar } from '@sveltia/ui';
33
import { onMount } from 'svelte';
4+
import { get } from 'svelte/store';
45
import { _ } from 'svelte-i18n';
56
67
import PageContainerMainArea from '$lib/components/common/page-container-main-area.svelte';
@@ -35,7 +36,7 @@
3536
3637
const { panelKey } = match.groups;
3738
38-
selectedPanel = panelKey ? panels.find((panel) => panel.key === panelKey) : undefined;
39+
selectedPanel = panelKey ? get(panels).find((panel) => panel.key === panelKey) : undefined;
3940
};
4041
4142
onMount(() => {
@@ -78,7 +79,7 @@
7879
</div>
7980
{:else}
8081
<Menu aria-label={$_('settings')}>
81-
{#each panels as { key, icon } (key)}
82+
{#each get(panels) as { key, icon } (key)}
8283
<MenuItem
8384
label={$_(`prefs.${key}.title`)}
8485
onclick={() => goto(`/settings/${key}`, { transitionType: 'forwards' })}

src/lib/locales/en.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,10 +681,17 @@ export const strings = {
681681
switch_label: 'Close the editor with the Escape key',
682682
},
683683
},
684+
},
685+
i18n: {
686+
title: 'Internationalization',
687+
default_translator: {
688+
title: 'Default Translation Service',
689+
select_service: 'Select Service',
690+
},
684691
translator: {
685-
field_label: '{service} API Key',
692+
field_label: '{service} Key',
686693
description:
687-
'Sign up for <a {homeHref}>{service} API</a> and enter <a {apiKeyHref}>your API Key</a> here to enable quick translation of text entry fields.',
694+
'Sign up for <a {homeHref}>{service}</a> and enter <a {apiKeyHref}>your API Key</a> here to enable quick translation of text entry fields.',
688695
},
689696
},
690697
media: {

src/lib/locales/ja.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,10 +685,17 @@ export const strings = {
685685
switch_label: 'Escape キーでエディターを閉じる',
686686
},
687687
},
688+
},
689+
i18n: {
690+
title: '国際化',
691+
default_translator: {
692+
title: 'デフォルト翻訳サービス',
693+
select_service: 'サービスを選択',
694+
},
688695
translator: {
689-
field_label: '{service} API キー',
696+
field_label: '{service} キー',
690697
description:
691-
'<a {homeHref}>{service} API</a> にユーザー登録して、<a {apiKeyHref}>発行されたキー</a> をここに入力すると、テキストエントリー項目の素早い翻訳が可能となります。',
698+
'<a {homeHref}>{service}</a> にユーザー登録して、<a {apiKeyHref}>発行されたキー</a> をここに入力すると、テキストエントリー項目の素早い翻訳が可能となります。',
692699
},
693700
},
694701
media: {

src/lib/services/contents/draft/update/copy.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@ const updateToast = (status, message, { count, sourceLocale }) => {
100100
* @param {CopyingFieldMap} args.copingFieldMap Copied or translated field values.
101101
*/
102102
const translateFields = async ({ currentValues, options, copingFieldMap }) => {
103-
const _translator = get(translator);
103+
const { serviceId, markdownSupported, translate } = get(translator);
104104
const { sourceLocale, targetLocale } = options;
105105
const count = Object.keys(copingFieldMap).length;
106106
const countType = count === 1 ? 'one' : 'many';
107107

108108
const apiKey =
109-
get(prefs).apiKeys?.[_translator.serviceId] ||
109+
get(prefs).apiKeys?.[serviceId] ||
110110
(await new Promise((resolve) => {
111111
// The promise will be resolved once the user enters an API key on the dialog
112112
translatorApiKeyDialogState.set({ show: true, multiple: count > 1, resolve });
@@ -119,23 +119,21 @@ const translateFields = async ({ currentValues, options, copingFieldMap }) => {
119119
updateToast('info', 'translation.started', { count, sourceLocale });
120120

121121
try {
122-
const translatedValues = await _translator.translate(
122+
const translatedValues = await translate(
123123
Object.entries(copingFieldMap).map(([, { value, isMarkdown }]) =>
124-
// Convert the value from Markdown to HTML if the field is a markdown field, because the
125-
// translator API expects HTML input
126-
isMarkdown ? /** @type {string} */ (marked.parse(value)) : value,
124+
// Convert the value from Markdown to HTML if needed
125+
isMarkdown && !markdownSupported ? /** @type {string} */ (marked.parse(value)) : value,
127126
),
128127
{ apiKey, sourceLocale, targetLocale },
129128
);
130129

131130
Object.entries(copingFieldMap).forEach(([_keyPath, { isMarkdown }], index) => {
132131
const value = translatedValues[index];
133132

134-
// Convert the value back to Markdown if the field is a markdown field
135-
currentValues[targetLocale][_keyPath] = isMarkdown
136-
? // @ts-ignore Silence a false type error
137-
turndownService.turndown(value)
138-
: value;
133+
// Convert the value back to Markdown if needed
134+
currentValues[targetLocale][_keyPath] =
135+
// @ts-ignore Silence a false type error
136+
isMarkdown && !markdownSupported ? turndownService.turndown(value) : value;
139137
});
140138

141139
updateToast('success', `translation.complete.${countType}`, { count, sourceLocale });

0 commit comments

Comments
 (0)