feat(i18n): localize Settings → General → Secrets (#2144)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activateLocale } from "@/lib/i18n";
|
||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||
import { DeleteSecretDialog } from "./secrets-settings";
|
||||
|
||||
/**
|
||||
* A secret's name is user data. It reaches the dialog as an interpolated value
|
||||
* and must never become a catalog key — the pseudo locale is the oracle here:
|
||||
* the sentence around the name changes, the name itself must not.
|
||||
*/
|
||||
const secret: SecretListItem = {
|
||||
id: "secret-1",
|
||||
name: "OpenAI Production Key",
|
||||
has_value: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* The name renders inside a `<span>`, so testing-library's text matcher — which
|
||||
* joins only an element's direct text-node children — never sees the sentence
|
||||
* whole. Read the description element's own `textContent` instead.
|
||||
*/
|
||||
function descriptionText(): string {
|
||||
const description = screen.getByText(secret.name).closest("p");
|
||||
// Fail on the broken query rather than on an empty string comparison, which
|
||||
// would point every assertion below at the wrong cause.
|
||||
if (!description) throw new Error("secret name is not inside the <p> DialogDescription");
|
||||
return description.textContent ?? "";
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
await activateLocale("en");
|
||||
});
|
||||
|
||||
describe("DeleteSecretDialog", () => {
|
||||
it("renders the confirmation copy around the secret name", () => {
|
||||
render(
|
||||
<DeleteSecretDialog target={secret} onClose={vi.fn()} onConfirm={vi.fn()} isBusy={false} />,
|
||||
);
|
||||
|
||||
expect(descriptionText()).toBe(
|
||||
`This will permanently remove ${secret.name}. This action cannot be undone.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the secret name verbatim when the locale changes", async () => {
|
||||
await activateLocale("pseudo");
|
||||
render(
|
||||
<DeleteSecretDialog target={secret} onClose={vi.fn()} onConfirm={vi.fn()} isBusy={false} />,
|
||||
);
|
||||
|
||||
// The surrounding sentence is catalog copy, so pseudo accents it.
|
||||
expect(descriptionText()).toMatch(/[À-ɏ]/);
|
||||
expect(descriptionText()).not.toContain("This will permanently remove");
|
||||
// The name is user data — byte-identical under any locale.
|
||||
expect(descriptionText()).toContain(secret.name);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconEdit, IconTrash, IconEye, IconEyeOff, IconKey } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ function SecretForm({
|
||||
showSubmit = true,
|
||||
baselineState,
|
||||
}: SecretFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const nameIsDirty = Boolean(baselineState) && formState.name.trim() !== baselineState?.name;
|
||||
const valueIsDirty = Boolean(baselineState) && formState.value !== baselineState?.value;
|
||||
return (
|
||||
@@ -76,14 +78,14 @@ function SecretForm({
|
||||
<Input
|
||||
value={formState.name}
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
placeholder="Name (e.g. OpenAI Production Key)"
|
||||
placeholder={t("settings:nameEGOpenaiProductionKey")}
|
||||
disabled={isBusy}
|
||||
data-settings-dirty={nameIsDirty}
|
||||
/>
|
||||
<Textarea
|
||||
value={formState.value}
|
||||
onChange={(e) => onFormChange({ value: e.target.value })}
|
||||
placeholder="Secret value"
|
||||
placeholder={t("settings:secretValue")}
|
||||
rows={2}
|
||||
className="resize-y font-mono text-sm"
|
||||
disabled={isBusy}
|
||||
@@ -97,7 +99,7 @@ function SecretForm({
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onCancel} disabled={isBusy} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("settings:cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +206,16 @@ type DeleteSecretDialogProps = {
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecretDialogProps) {
|
||||
export function DeleteSecretDialog({
|
||||
target,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isBusy,
|
||||
}: DeleteSecretDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// The secret's own name is user data: it is interpolated as a value and never
|
||||
// routed through a catalog key.
|
||||
const name = target?.name ?? t("settings:thisSecret");
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(target)}
|
||||
@@ -214,16 +225,18 @@ function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecret
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete secret</DialogTitle>
|
||||
<DialogTitle>{t("settings:deleteSecret")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove{" "}
|
||||
<span className="font-medium text-foreground">{target?.name ?? "this secret"}</span>.
|
||||
This action cannot be undone.
|
||||
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name }}>
|
||||
This will permanently remove{" "}
|
||||
<span className="font-medium text-foreground">{name}</span>. This action cannot be
|
||||
undone.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("settings:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -232,7 +245,7 @@ function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecret
|
||||
disabled={isBusy}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Delete secret
|
||||
{t("settings:deleteSecret")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -393,6 +406,7 @@ export function getSecretDraftMeta(
|
||||
}
|
||||
|
||||
export function SecretsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const state = useSecretsState();
|
||||
const { loaded, editingId, showCreate, formState, setFormState, deleteTarget, items } = state;
|
||||
const actions = useSecretsActions(state);
|
||||
@@ -401,8 +415,8 @@ export function SecretsSettings() {
|
||||
let invalidReason: string | undefined;
|
||||
if (isDirty && !isValid) {
|
||||
invalidReason = showCreate
|
||||
? "A secret name and value are required."
|
||||
: "A secret name is required.";
|
||||
? t("settings:aSecretNameAndValueAreRequired")
|
||||
: t("settings:aSecretNameIsRequired");
|
||||
}
|
||||
|
||||
const onFormChange = (patch: Partial<SecretFormState>) =>
|
||||
@@ -410,8 +424,8 @@ export function SecretsSettings() {
|
||||
|
||||
return (
|
||||
<SettingsPageTemplate
|
||||
title="Secrets"
|
||||
description="Manage API keys and credentials. Secrets are encrypted at rest and injected into agent environments via executor profile env vars."
|
||||
title={t("settings:secrets")}
|
||||
description={t("settings:manageApiKeysAndCredentialsSecrets")}
|
||||
isDirty={isDirty}
|
||||
saveStatus="idle"
|
||||
saveId="secrets-item-draft"
|
||||
@@ -423,26 +437,26 @@ export function SecretsSettings() {
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-foreground">Secrets</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("settings:secrets")}</div>
|
||||
<Button
|
||||
onClick={actions.startCreate}
|
||||
disabled={isBusy || Boolean(editingId) || showCreate}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Add secret
|
||||
{t("settings:addSecret")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<SecretForm
|
||||
title="New secret"
|
||||
title={t("settings:newSecret")}
|
||||
formState={formState}
|
||||
onFormChange={onFormChange}
|
||||
onSubmit={actions.handleCreate}
|
||||
onCancel={actions.resetForm}
|
||||
isValid={isValid}
|
||||
isBusy={isBusy}
|
||||
submitLabel="Add secret"
|
||||
submitLabel={t("settings:addSecret")}
|
||||
showSubmit={false}
|
||||
baselineState={defaultFormState}
|
||||
/>
|
||||
@@ -450,14 +464,14 @@ export function SecretsSettings() {
|
||||
|
||||
{editingId && (
|
||||
<SecretForm
|
||||
title="Edit secret"
|
||||
title={t("settings:editSecret")}
|
||||
formState={formState}
|
||||
onFormChange={onFormChange}
|
||||
onSubmit={actions.handleUpdate}
|
||||
onCancel={actions.resetForm}
|
||||
isValid={isValid}
|
||||
isBusy={isBusy}
|
||||
submitLabel="Save changes"
|
||||
submitLabel={t("settings:saveChanges")}
|
||||
showSubmit={false}
|
||||
baselineState={edit.baseline}
|
||||
/>
|
||||
@@ -466,12 +480,12 @@ export function SecretsSettings() {
|
||||
<div className="space-y-3">
|
||||
{!loaded && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
Loading secrets...
|
||||
{t("settings:loadingSecrets")}
|
||||
</div>
|
||||
)}
|
||||
{loaded && items.length === 0 && !showCreate && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
No secrets yet. Add your first secret to get started.
|
||||
{t("settings:noSecretsYetAddYourFirst")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((secret) => (
|
||||
|
||||
@@ -26,7 +26,10 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
const COVERAGE_ENABLED = process.env.KANDEV_I18N_COVERAGE === "1";
|
||||
|
||||
/** Migrated screens whose visible text is overwhelmingly UI chrome, not user data. */
|
||||
const SCREENS = [{ name: "settings — appearance", url: "/settings/general/appearance" }];
|
||||
const SCREENS = [
|
||||
{ name: "settings — appearance", url: "/settings/general/appearance" },
|
||||
{ name: "settings — secrets", url: "/settings/general/secrets" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Text that is legitimately un-accented under pseudo: brand/proper nouns, code
|
||||
|
||||
@@ -172,10 +172,13 @@ export const i18nGuardFiles = [
|
||||
// Settings → General → Appearance, migrated end-to-end as the worked example:
|
||||
// the page, the two sections it owns, and the settings chrome around them.
|
||||
"app/settings/general/appearance/**/*.{ts,tsx}",
|
||||
// Settings → General → Secrets.
|
||||
"app/settings/general/secrets/**/*.{ts,tsx}",
|
||||
"components/app-sidebar/sections/settings/general-group.tsx",
|
||||
"components/settings/general-settings.tsx",
|
||||
"components/settings/general-nav.ts",
|
||||
"components/settings/language-settings.tsx",
|
||||
"components/settings/secrets-settings.tsx",
|
||||
"components/settings/settings-floating-save.tsx",
|
||||
"components/settings/settings-layout-client.tsx",
|
||||
"components/settings/startup-page-settings-card.tsx",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"addSecret": "Add secret",
|
||||
"agent": "Agent",
|
||||
"appearance": "Appearance",
|
||||
"aSecretNameAndValueAreRequired": "A secret name and value are required.",
|
||||
"aSecretNameIsRequired": "A secret name is required.",
|
||||
"cancel": "Cancel",
|
||||
"changesPanel": "Changes Panel",
|
||||
"changesPanelLayout": "Changes Panel Layout",
|
||||
"changesTheLanguageOfTheKandev": "Changes the language of the Kandev interface. Applies immediately and is remembered on this device. Task titles, chat, and other content you create are not translated.",
|
||||
@@ -26,6 +30,7 @@
|
||||
"customizeHowTheApplicationLooks": "Customize how the application looks",
|
||||
"customizeKeyboardShortcutsForTheCommand": "Customize keyboard shortcuts for the command panel",
|
||||
"dark": "Dark",
|
||||
"deleteSecret": "Delete secret",
|
||||
"discardAndLeave": "Discard and leave",
|
||||
"discarding": "Discarding...",
|
||||
"disk": "Disk %",
|
||||
@@ -34,6 +39,7 @@
|
||||
"displayLanguage": "Display language",
|
||||
"editorIntegrationsAndDefaults": "Editor integrations and defaults",
|
||||
"editors": "Editors",
|
||||
"editSecret": "Edit secret",
|
||||
"enterToSend": "Enter to send",
|
||||
"fileListView": "File list view",
|
||||
"flatList": "Flat list",
|
||||
@@ -45,12 +51,17 @@
|
||||
"lastVisitedTask": "Last visited task",
|
||||
"layouts": "Layouts",
|
||||
"light": "Light",
|
||||
"loadingSecrets": "Loading secrets...",
|
||||
"makesAgentctlValuesAvailableToPlugins": "Makes agentctl values available to plugins and other consumers. The built-in status bar remains host-only.",
|
||||
"manageApiKeysAndCredentialsSecrets": "Manage API keys and credentials. Secrets are encrypted at rest and injected into agent environments via executor profile env vars.",
|
||||
"mcpTaskDefaultsAndArchiveSafeguards": "MCP task defaults, archive safeguards, and transcript preferences",
|
||||
"memory": "Memory %",
|
||||
"messageSubmitKey": "Message Submit Key",
|
||||
"metrics": "Metrics",
|
||||
"nameEGOpenaiProductionKey": "Name (e.g. OpenAI Production Key)",
|
||||
"new": "New",
|
||||
"newSecret": "New secret",
|
||||
"noSecretsYetAddYourFirst": "No secrets yet. Add your first secret to get started.",
|
||||
"notifications": "Notifications",
|
||||
"openKandevTo": "Open Kandev to",
|
||||
"openSettingsMenu": "Open settings menu",
|
||||
@@ -69,6 +80,7 @@
|
||||
"saving": "Saving...",
|
||||
"savingChanges": "Saving changes",
|
||||
"secrets": "Secrets",
|
||||
"secretValue": "Secret value",
|
||||
"settings": "Settings",
|
||||
"shellTerminalFontsAndLinkBehavior": "Shell, terminal fonts, and link behavior",
|
||||
"showHostMetricsInStatusBar": "Show host metrics in status bar",
|
||||
@@ -87,6 +99,8 @@
|
||||
"themeMetricsAndChangesPanelPreferences": "Theme, startup page, metrics, and changes panel preferences",
|
||||
"thisAppliesWhenKandevStarts": "This applies when Kandev starts or you open its bare home page. Home, Back, and workflow navigation always open the task overview.",
|
||||
"thisPageHasUnsavedChangesSave": "This page has unsaved changes. Save them, discard them, or continue editing.",
|
||||
"thisSecret": "this secret",
|
||||
"thisWillPermanentlyRemoveSecret": "This will permanently remove <2>{{name}}</2>. This action cannot be undone.",
|
||||
"transcriptNavigation": "Transcript Navigation",
|
||||
"tree": "Tree",
|
||||
"usefulWhenKandevIsSelfHosted": "Useful when Kandev is self-hosted on a remote server and you want a lightweight view of the machine resources from the global status bar or phone Status drawer.",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"addSecret": "Àďď śēćŕēţ",
|
||||
"agent": "Àĝēńţ",
|
||||
"appearance": "Àƥƥēàŕàńćē",
|
||||
"aSecretNameAndValueAreRequired": "À śēćŕēţ ńàḿē àńď vàĺũē àŕē ŕēqũĩŕēď.",
|
||||
"aSecretNameIsRequired": "À śēćŕēţ ńàḿē ĩś ŕēqũĩŕēď.",
|
||||
"cancel": "Ćàńćēĺ",
|
||||
"changesPanel": "Ćĥàńĝēś Ƥàńēĺ",
|
||||
"changesPanelLayout": "Ćĥàńĝēś Ƥàńēĺ Ĺàŷōũţ",
|
||||
"changesTheLanguageOfTheKandev": "Ćĥàńĝēś ţĥē ĺàńĝũàĝē ōƒ ţĥē Ķàńďēv ĩńţēŕƒàćē. Àƥƥĺĩēś ĩḿḿēďĩàţēĺŷ àńď ĩś ŕēḿēḿƀēŕēď ōń ţĥĩś ďēvĩćē. Ţàśķ ţĩţĺēś, ćĥàţ, àńď ōţĥēŕ ćōńţēńţ ŷōũ ćŕēàţē àŕē ńōţ ţŕàńśĺàţēď.",
|
||||
@@ -26,6 +30,7 @@
|
||||
"customizeHowTheApplicationLooks": "Ćũśţōḿĩźē ĥōŵ ţĥē àƥƥĺĩćàţĩōń ĺōōķś",
|
||||
"customizeKeyboardShortcutsForTheCommand": "Ćũśţōḿĩźē ķēŷƀōàŕď śĥōŕţćũţś ƒōŕ ţĥē ćōḿḿàńď ƥàńēĺ",
|
||||
"dark": "Ďàŕķ",
|
||||
"deleteSecret": "Ďēĺēţē śēćŕēţ",
|
||||
"discardAndLeave": "Ďĩśćàŕď àńď ĺēàvē",
|
||||
"discarding": "Ďĩśćàŕďĩńĝ...",
|
||||
"disk": "Ďĩśķ %",
|
||||
@@ -34,6 +39,7 @@
|
||||
"displayLanguage": "Ďĩśƥĺàŷ ĺàńĝũàĝē",
|
||||
"editorIntegrationsAndDefaults": "Ēďĩţōŕ ĩńţēĝŕàţĩōńś àńď ďēƒàũĺţś",
|
||||
"editors": "Ēďĩţōŕś",
|
||||
"editSecret": "Ēďĩţ śēćŕēţ",
|
||||
"enterToSend": "Ēńţēŕ ţō śēńď",
|
||||
"fileListView": "Ƒĩĺē ĺĩśţ vĩēŵ",
|
||||
"flatList": "Ƒĺàţ ĺĩśţ",
|
||||
@@ -45,12 +51,17 @@
|
||||
"lastVisitedTask": "Ĺàśţ vĩśĩţēď ţàśķ",
|
||||
"layouts": "Ĺàŷōũţś",
|
||||
"light": "Ĺĩĝĥţ",
|
||||
"loadingSecrets": "Ĺōàďĩńĝ śēćŕēţś...",
|
||||
"makesAgentctlValuesAvailableToPlugins": "Ḿàķēś àĝēńţćţĺ vàĺũēś àvàĩĺàƀĺē ţō ƥĺũĝĩńś àńď ōţĥēŕ ćōńśũḿēŕś. Ţĥē ƀũĩĺţ-ĩń śţàţũś ƀàŕ ŕēḿàĩńś ĥōśţ-ōńĺŷ.",
|
||||
"manageApiKeysAndCredentialsSecrets": "Ḿàńàĝē ÀƤĨ ķēŷś àńď ćŕēďēńţĩàĺś. Śēćŕēţś àŕē ēńćŕŷƥţēď àţ ŕēśţ àńď ĩńĵēćţēď ĩńţō àĝēńţ ēńvĩŕōńḿēńţś vĩà ēxēćũţōŕ ƥŕōƒĩĺē ēńv vàŕś.",
|
||||
"mcpTaskDefaultsAndArchiveSafeguards": "ḾĆƤ ţàśķ ďēƒàũĺţś, àŕćĥĩvē śàƒēĝũàŕďś, àńď ţŕàńśćŕĩƥţ ƥŕēƒēŕēńćēś",
|
||||
"memory": "Ḿēḿōŕŷ %",
|
||||
"messageSubmitKey": "Ḿēśśàĝē Śũƀḿĩţ Ķēŷ",
|
||||
"metrics": "Ḿēţŕĩćś",
|
||||
"nameEGOpenaiProductionKey": "Ńàḿē (ē.ĝ. ŌƥēńÀĨ Ƥŕōďũćţĩōń Ķēŷ)",
|
||||
"new": "Ńēŵ",
|
||||
"newSecret": "Ńēŵ śēćŕēţ",
|
||||
"noSecretsYetAddYourFirst": "Ńō śēćŕēţś ŷēţ. Àďď ŷōũŕ ƒĩŕśţ śēćŕēţ ţō ĝēţ śţàŕţēď.",
|
||||
"notifications": "Ńōţĩƒĩćàţĩōńś",
|
||||
"openKandevTo": "Ōƥēń Ķàńďēv ţō",
|
||||
"openSettingsMenu": "Ōƥēń śēţţĩńĝś ḿēńũ",
|
||||
@@ -69,6 +80,7 @@
|
||||
"saving": "Śàvĩńĝ...",
|
||||
"savingChanges": "Śàvĩńĝ ćĥàńĝēś",
|
||||
"secrets": "Śēćŕēţś",
|
||||
"secretValue": "Śēćŕēţ vàĺũē",
|
||||
"settings": "Śēţţĩńĝś",
|
||||
"shellTerminalFontsAndLinkBehavior": "Śĥēĺĺ, ţēŕḿĩńàĺ ƒōńţś, àńď ĺĩńķ ƀēĥàvĩōŕ",
|
||||
"showHostMetricsInStatusBar": "Śĥōŵ ĥōśţ ḿēţŕĩćś ĩń śţàţũś ƀàŕ",
|
||||
@@ -87,6 +99,8 @@
|
||||
"themeMetricsAndChangesPanelPreferences": "Ţĥēḿē, śţàŕţũƥ ƥàĝē, ḿēţŕĩćś, àńď ćĥàńĝēś ƥàńēĺ ƥŕēƒēŕēńćēś",
|
||||
"thisAppliesWhenKandevStarts": "Ţĥĩś àƥƥĺĩēś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ ōƥēń ĩţś ƀàŕē ĥōḿē ƥàĝē. Ĥōḿē, Ɓàćķ, àńď ŵōŕķƒĺōŵ ńàvĩĝàţĩōń àĺŵàŷś ōƥēń ţĥē ţàśķ ōvēŕvĩēŵ.",
|
||||
"thisPageHasUnsavedChangesSave": "Ţĥĩś ƥàĝē ĥàś ũńśàvēď ćĥàńĝēś. Śàvē ţĥēḿ, ďĩśćàŕď ţĥēḿ, ōŕ ćōńţĩńũē ēďĩţĩńĝ.",
|
||||
"thisSecret": "ţĥĩś śēćŕēţ",
|
||||
"thisWillPermanentlyRemoveSecret": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"transcriptNavigation": "Ţŕàńśćŕĩƥţ Ńàvĩĝàţĩōń",
|
||||
"tree": "Ţŕēē",
|
||||
"usefulWhenKandevIsSelfHosted": "Ũśēƒũĺ ŵĥēń Ķàńďēv ĩś śēĺƒ-ĥōśţēď ōń à ŕēḿōţē śēŕvēŕ àńď ŷōũ ŵàńţ à ĺĩĝĥţŵēĩĝĥţ vĩēŵ ōƒ ţĥē ḿàćĥĩńē ŕēśōũŕćēś ƒŕōḿ ţĥē ĝĺōƀàĺ śţàţũś ƀàŕ ōŕ ƥĥōńē Śţàţũś ďŕàŵēŕ.",
|
||||
|
||||
Reference in New Issue
Block a user