feat(download): enrich error feedback links (#101)
* feat(download): enrich feedback payloads * fix(ci): stabilize locale and ffmpeg fetch
This commit is contained in:
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
@@ -98,6 +98,13 @@ jobs:
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
|
||||
fi
|
||||
if [[ ! -f "$x86_bin" ]]; then
|
||||
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||
exit 1
|
||||
|
||||
@@ -16,6 +16,37 @@ class AppService extends IpcService {
|
||||
return os.platform()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getOsVersion(_context: IpcContext): string {
|
||||
const platform = os.platform()
|
||||
const platformLabel =
|
||||
platform === 'darwin'
|
||||
? 'macOS'
|
||||
: platform === 'win32'
|
||||
? 'Windows'
|
||||
: platform === 'linux'
|
||||
? 'Linux'
|
||||
: platform
|
||||
const systemVersion =
|
||||
typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'
|
||||
? (process as { getSystemVersion: () => string }).getSystemVersion()
|
||||
: typeof os.version === 'function'
|
||||
? os.version()
|
||||
: os.release()
|
||||
|
||||
if (platform === 'win32') {
|
||||
const buildToken = systemVersion.split('.').at(-1) ?? ''
|
||||
const buildNumber = Number.parseInt(buildToken, 10)
|
||||
const windowsName =
|
||||
Number.isFinite(buildNumber) && buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
|
||||
return Number.isFinite(buildNumber)
|
||||
? `${windowsName} (build ${buildNumber})`
|
||||
: `${platformLabel} ${systemVersion}`.trim()
|
||||
}
|
||||
|
||||
return `${platformLabel} ${systemVersion}`.trim()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
quit(_context: IpcContext): void {
|
||||
app.quit()
|
||||
|
||||
@@ -20,7 +20,17 @@ import {
|
||||
buildVideoFormatPreference
|
||||
} from '@shared/utils/format-preferences'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, FolderOpen, List, Loader2, Plus, Video } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
FolderOpen,
|
||||
Github,
|
||||
List,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Twitter,
|
||||
Video
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -49,6 +59,35 @@ const isLikelyUrl = (value: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeErrorText = (value?: string | null): string =>
|
||||
value ? value.replace(/\s+/g, ' ').trim() : ''
|
||||
|
||||
const clampText = (value: string, maxLength: number): string =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
|
||||
|
||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||
const FEEDBACK_ISSUE_TITLE = 'Download error report'
|
||||
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
|
||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
|
||||
|
||||
const buildIssueLogs = (
|
||||
errorText: string,
|
||||
sourceUrl: string | undefined,
|
||||
urlLabel: string,
|
||||
errorLabel: string
|
||||
): string => {
|
||||
const lines: string[] = []
|
||||
if (sourceUrl) {
|
||||
lines.push(`${urlLabel}: ${sourceUrl}`)
|
||||
}
|
||||
lines.push(`${errorLabel}: ${errorText}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const isAudioOnlyFormat = (format: VideoFormat): boolean =>
|
||||
!!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none')
|
||||
|
||||
@@ -112,6 +151,8 @@ export function DownloadDialog({
|
||||
onOpenSettings: _onOpenSettings
|
||||
}: DownloadDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [osVersion, setOsVersion] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
|
||||
const [loading] = useAtom(videoInfoLoadingAtom)
|
||||
@@ -151,6 +192,74 @@ export function DownloadDialog({
|
||||
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
|
||||
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||
const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set())
|
||||
const feedbackLinks = useMemo(() => {
|
||||
const compactError = normalizeErrorText(error)
|
||||
const tweetError = compactError ? clampText(compactError, 160) : ''
|
||||
const tweetText = encodeURIComponent(
|
||||
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
|
||||
)
|
||||
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
||||
const issueTitle = FEEDBACK_ISSUE_TITLE
|
||||
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
|
||||
const sourceUrl = url.trim() || undefined
|
||||
const issueLogs = clampText(
|
||||
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
|
||||
800
|
||||
)
|
||||
const appVersionValue = appVersion
|
||||
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
|
||||
: FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
|
||||
issueTitle
|
||||
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
|
||||
issueLogs
|
||||
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
|
||||
osVersionValue
|
||||
)}`
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
href: `https://x.com/intent/tweet?text=${tweetText}`
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
]
|
||||
}, [appVersion, error, osVersion, t, url])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
const loadAppInfo = async () => {
|
||||
try {
|
||||
const [version, osRelease] = await Promise.all([
|
||||
ipcServices.app.getVersion(),
|
||||
ipcServices.app.getOsVersion()
|
||||
])
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
setAppVersion(version)
|
||||
setOsVersion(osRelease)
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load app info for feedback links:', loadError)
|
||||
}
|
||||
}
|
||||
|
||||
void loadAppInfo()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const computePlaylistRange = useCallback(
|
||||
(info: PlaylistInfo) => {
|
||||
@@ -891,6 +1000,30 @@ export function DownloadDialog({
|
||||
<p className="text-xs text-muted-foreground wrap-break-word">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-[10px]"
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-3 w-3" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Github,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Play,
|
||||
Trash2,
|
||||
Twitter,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
@@ -200,8 +203,72 @@ const formatDateShort = (timestamp?: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeErrorText = (value?: string | null): string =>
|
||||
value ? value.replace(/\s+/g, ' ').trim() : ''
|
||||
|
||||
const clampText = (value: string, maxLength: number): string =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
|
||||
|
||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||
const FEEDBACK_ISSUE_TITLE = 'Download error report'
|
||||
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
|
||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
|
||||
|
||||
const buildIssueLogs = (
|
||||
errorText: string,
|
||||
sourceUrl: string | undefined,
|
||||
urlLabel: string,
|
||||
errorLabel: string
|
||||
): string => {
|
||||
const lines: string[] = []
|
||||
if (sourceUrl) {
|
||||
lines.push(`${urlLabel}: ${sourceUrl}`)
|
||||
}
|
||||
lines.push(`${errorLabel}: ${errorText}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
type AppInfo = {
|
||||
appVersion: string
|
||||
osVersion: string
|
||||
}
|
||||
|
||||
let cachedAppInfo: AppInfo | null = null
|
||||
let appInfoPromise: Promise<AppInfo> | null = null
|
||||
|
||||
const loadAppInfo = async (): Promise<AppInfo> => {
|
||||
if (cachedAppInfo) {
|
||||
return cachedAppInfo
|
||||
}
|
||||
if (appInfoPromise) {
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
appInfoPromise = (async () => {
|
||||
try {
|
||||
const [version, osRelease] = await Promise.all([
|
||||
ipcServices.app.getVersion(),
|
||||
ipcServices.app.getOsVersion()
|
||||
])
|
||||
cachedAppInfo = { appVersion: version, osVersion: osRelease }
|
||||
} catch (error) {
|
||||
console.error('Failed to load app info for feedback links:', error)
|
||||
cachedAppInfo = { appVersion: '', osVersion: '' }
|
||||
}
|
||||
return cachedAppInfo
|
||||
})()
|
||||
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [osVersion, setOsVersion] = useState('')
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
@@ -214,6 +281,66 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||
const feedbackLinks = useMemo(() => {
|
||||
const compactError = normalizeErrorText(download.error)
|
||||
const tweetError = compactError ? clampText(compactError, 160) : ''
|
||||
const tweetText = encodeURIComponent(
|
||||
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
|
||||
)
|
||||
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
||||
const issueTitle = FEEDBACK_ISSUE_TITLE
|
||||
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
|
||||
const sourceUrl = download.url?.trim() || undefined
|
||||
const issueLogs = clampText(
|
||||
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
|
||||
800
|
||||
)
|
||||
const appVersionValue = appVersion
|
||||
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
|
||||
: FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
|
||||
issueTitle
|
||||
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
|
||||
issueLogs
|
||||
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
|
||||
osVersionValue
|
||||
)}`
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
href: `https://x.com/intent/tweet?text=${tweetText}`
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
]
|
||||
}, [appVersion, download.error, download.url, osVersion, t])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const fetchInfo = async () => {
|
||||
const info = await loadAppInfo()
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
setAppVersion(info.appVersion)
|
||||
setOsVersion(info.osVersion)
|
||||
}
|
||||
|
||||
void fetchInfo()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
@@ -656,7 +783,7 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
|
||||
className={`flex w-full flex-col gap-2 sm:flex-row sm:gap-3 ${
|
||||
selectionEnabled ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
{...(selectionEnabled
|
||||
@@ -701,9 +828,9 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
|
||||
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
|
||||
<div className="flex-1 min-w-0 max-w-full overflow-hidden pointer-events-none">
|
||||
<div className="flex items-center justify-center h-14 w-full flex-col gap-1.5 sm:flex-row sm:justify-between sm:gap-2">
|
||||
<div className="flex-1 items-center min-w-0 max-w-full space-y-1.5 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
|
||||
{download.title}
|
||||
@@ -929,9 +1056,36 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
|
||||
{/* Error message */}
|
||||
{download.status === 'error' && download.error && (
|
||||
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
|
||||
{download.error}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
|
||||
{download.error}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground pointer-events-auto">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-[10px]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-3 w-3" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "ملاحظة التنسيق",
|
||||
"protocol": "البروتوكول",
|
||||
"subscription": "الاشتراك"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Format-Hinweis",
|
||||
"protocol": "Protokoll",
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
"error": "Error",
|
||||
"fetch": "Fetch",
|
||||
"fetchingVideoInfo": "Fetching video info...",
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
},
|
||||
"history": "History",
|
||||
"imageLoadError": "Image failed to load",
|
||||
"imagePlaceholder": "No image available",
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Nota de formato",
|
||||
"protocol": "Protocolo",
|
||||
"subscription": "Suscripción"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Remarque sur le format",
|
||||
"protocol": "Protocole",
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Catatan format",
|
||||
"protocol": "Protokol",
|
||||
"subscription": "Berlangganan"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Nota sul formato",
|
||||
"protocol": "Protocollo",
|
||||
"subscription": "Sottoscrizione"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "メモのフォーマット",
|
||||
"protocol": "プロトコル",
|
||||
"subscription": "サブスクリプション"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "메모 형식",
|
||||
"protocol": "규약",
|
||||
"subscription": "신청"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Formatar nota",
|
||||
"protocol": "Protocolo",
|
||||
"subscription": "Subscrição"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "Примечание формата",
|
||||
"protocol": "Протокол",
|
||||
"subscription": "Подписка"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "格式註釋",
|
||||
"protocol": "協定",
|
||||
"subscription": "訂閱"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"formatNote": "格式注释",
|
||||
"protocol": "协议",
|
||||
"subscription": "订阅"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in New Issue
Block a user