diff --git a/apps/app/package.json b/apps/app/package.json index 8346c25e..6fd55a5d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -15,7 +15,6 @@ "@mx/config": "workspace:*", "@total-typescript/ts-reset": "^0.5.1", "@types/eslint": "~8.4.6", - "@types/language-tags": "^1.0.4", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "autoprefixer": "^10.4.16", @@ -50,7 +49,6 @@ "filenamify": "^6.0.0", "idb": "^8.0.0", "iso-639-1": "^3.1.0", - "language-tags": "^1.0.9", "maverick.js": "0.41.2", "mime": "^4.0.1", "monkey-around": "^2.3.0", diff --git a/apps/app/src/lib/group-by.ts b/apps/app/src/lib/group-by.ts new file mode 100644 index 00000000..7ac4e3d3 --- /dev/null +++ b/apps/app/src/lib/group-by.ts @@ -0,0 +1,13 @@ +export function groupBy(array: T[], getKey: (item: T) => K): Map { + const map = new Map(); + for (const item of array) { + const key = getKey(item); + const group = map.get(key); + if (group) { + group.push(item); + } else { + map.set(key, [item]); + } + } + return map; +} diff --git a/apps/app/src/lib/lang/lang.ts b/apps/app/src/lib/lang/lang.ts new file mode 100644 index 00000000..f5ef635b --- /dev/null +++ b/apps/app/src/lib/lang/lang.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import iso from "iso-639-1"; +import { groupBy } from "../group-by"; + +export const langExtra = { + "de-AT": "Österreichisches Deutsch", + "de-CH": "Schweizer Hochdeutsch", + "en-AU": "Australian English", + "en-CA": "Canadian English", + "en-GB": "British English", + "en-US": "American English", + "es-ES": "español de España", + "es-MX": "español de México", + "fr-CA": "français canadien", + "fr-CH": "français suisse", + "nl-BE": "Vlaams", + "pt-BR": "português do Brasil", + "pt-PT": "português europeu", + "ro-MD": "moldovenească", + "zh-Hans": "简体中文", + "zh-Hant": "繁體中文", +} as Record; + +export const getGroupedLangExtra = () => + groupBy(Object.entries(langExtra), ([k]) => k.split("-")[0]); + +export const countryMap = { + "zh-Hans": ["CN", "SG", "MY"], + "zh-Hant": ["TW", "HK", "MO"], +} as Record; + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +export function langCodeToLabel(code: string): string { + const tags = code.split("-"); + const lang = tags[0].toLowerCase(); + if (tags.length === 1) { + return iso.getNativeName(lang); + } + const langCountry = tags.slice(0, 2).join("-"); + return langExtra[langCountry] || `${iso.getNativeName(tags[0])} (${code})`; +} + +function detectChs(subtag: string) { + if ( + subtag.toLowerCase() === "hans" || + countryMap["zh-Hans"].includes(subtag.toUpperCase()) + ) + return "zh-Hans"; + if ( + subtag.toLowerCase() === "hant" || + countryMap["zh-Hant"].includes(subtag.toUpperCase()) + ) + return "zh-Hant"; + return "zh"; +} + +export function vaildate(code: string) { + const lang = code.split("-")[0].toLowerCase(); + return iso.validate(lang); +} + +export function format(code: string) { + if (!vaildate(code)) return null; + const tags = code.split("-"); + const lang = tags[0].toLowerCase(); + if (tags.length === 1) return lang; + const subtag = tags[1]; + if (lang === "zh") return detectChs(subtag); + return ( + langExtra[`${lang}-${subtag.toUpperCase()}`] ?? + `${lang}-${tags.slice(1).join("-")}` + ); +} diff --git a/apps/app/src/lib/subtitle.ts b/apps/app/src/lib/subtitle.ts index ffe04724..405f55c2 100644 --- a/apps/app/src/lib/subtitle.ts +++ b/apps/app/src/lib/subtitle.ts @@ -1,10 +1,11 @@ import { readdir, readFile } from "fs/promises"; import { basename, dirname, join } from "path"; import type { TextTrackInit } from "@vidstack/react"; -import tag, { type Tag } from "language-tags"; import type { Vault } from "obsidian"; import { Notice, TFile } from "obsidian"; import type { MediaURL } from "@/web/url-match"; +import { groupBy } from "./group-by"; +import { format, langCodeToLabel } from "./lang/lang"; const supportedFormat = ["vtt", "ass", "ssa", "srt"] as const; function isCaptionsFile(ext: string): ext is (typeof supportedFormat)[number] { @@ -14,67 +15,28 @@ function isCaptionsFile(ext: string): ext is (typeof supportedFormat)[number] { export function getTracks( mediaBasename: string, siblings: F[], - defaultLang?: string, + defaultLangCode?: string, ): LocalTrack[] { const subtitles = siblings.flatMap((file) => { const track = toTrack(file, mediaBasename); if (!track) return []; return [track]; }); - const defaultLangTag = defaultLang ? tag(defaultLang) : undefined; const subtitlesByLang = groupBy(subtitles, (v) => v.language); - const allLanguages = [...subtitlesByLang.keys()].map((t) => - t ? tag(t) : undefined, - ); - const subtitleDefaultLang = !defaultLang + const allLanguages = [...subtitlesByLang.keys()]; + const subtitleDefaultLang = !defaultLangCode ? allLanguages.filter((l) => !!l)[0] - : allLanguages.find((tag) => { + : allLanguages.find((code) => { // exact match - if (!tag) return; - return tag.format() === defaultLangTag?.format(); - }) ?? - allLanguages.find((tag) => { - // script or region code match - if (!tag) return; - const lang = tag.language(); - const langDefault = defaultLangTag?.language(); - if (!lang || !langDefault || lang.format() !== langDefault.format()) - return false; - const script = tag.script(), - scriptDefault = defaultLangTag?.script(); - const region = tag.region(), - regionDefault = defaultLangTag?.region(); - if (lang.format() === "zh") { - if (scriptDefault?.format() === "Hans") { - return ( - script?.format() === "Hans" || - region?.format() === "CN" || - region?.format() === "SG" - ); - } else if (scriptDefault?.format() === "Hant") { - return ( - script?.format() === "Hant" || - region?.format() === "TW" || - region?.format() === "HK" || - region?.format() === "MO" - ); - } - } - return ( - (script && - scriptDefault && - script.format() === scriptDefault.format()) || - (region && - regionDefault && - region.format() === regionDefault.format()) - ); + if (!code) return; + return code === defaultLangCode; }) ?? - allLanguages.find((tag) => { + allLanguages.find((code) => { // only language match - if (!tag) return; - const lang = tag.language(); - if (!lang) return; - return lang.format() === defaultLangTag!.language()?.format(); + if (!code) return; + const lang = code.split("-")[0], + defaultLang = defaultLangCode.split("-")[0]; + return lang === defaultLang; }); const uniqueTracks: LocalTrack[] = []; @@ -84,8 +46,7 @@ export function getTracks( if (track) { uniqueTracks.push({ ...track, - default: - !!subtitleDefaultLang && lang === subtitleDefaultLang.format(), + default: !!subtitleDefaultLang && lang === subtitleDefaultLang, }); return; } @@ -173,20 +134,6 @@ export async function getTracksInVault( ); } -function groupBy(array: T[], getKey: (item: T) => K): Map { - const map = new Map(); - for (const item of array) { - const key = getKey(item); - const group = map.get(key); - if (group) { - group.push(item); - } else { - map.set(key, [item]); - } - } - return map; -} - interface LocalTrack { id: string; kind: "subtitles"; @@ -224,30 +171,17 @@ function toTrack( default: false, }; } - const [fileBasename, language, ...rest] = file.basename.split("."); - if (fileBasename !== basename || rest.length > 0 || !tag.check(language)) - return null; + const [fileBasename, lan, ...rest] = file.basename.split("."); + const langCode = format(lan); + if (fileBasename !== basename || rest.length > 0 || !langCode) return null; - const langTag = tag(language); return { kind: "subtitles", - language: langTag.format(), - id: `${file.basename}.${file.extension}.${language}`, + language: langCode, + id: `${file.basename}.${file.extension}.${langCode}`, src: file, type: file.extension, - label: langTagToLabel(langTag, file.extension), + label: `${langCodeToLabel(langCode)} (${file.extension})`, default: false, }; } - -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -export function langTagToLabel(lang: Tag, comment: string) { - const primaryDesc = lang.descriptions()[0]; - if (primaryDesc) return `${primaryDesc} (${comment})`; - const langDesc = lang.language()?.descriptions()[0]; - const scriptDesc = lang.script()?.descriptions()[0]; - const regionDesc = lang.region()?.descriptions()[0]; - if (!langDesc) return `${lang.format()} (${comment})`; - if (!scriptDesc && !regionDesc) return langDesc; - return `${langDesc} (${scriptDesc || regionDesc}, ${comment})`; -} diff --git a/apps/app/src/settings/def.ts b/apps/app/src/settings/def.ts index 92c180df..901467c3 100644 --- a/apps/app/src/settings/def.ts +++ b/apps/app/src/settings/def.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { assertNever } from "assert-never"; -import type { Tag } from "language-tags"; -import tag from "language-tags"; import type { PaneType } from "obsidian"; import { Notice, Platform, debounce, moment } from "obsidian"; import { createStore } from "zustand"; +import { vaildate } from "@/lib/lang/lang"; import { enumerate } from "@/lib/must-include"; import { pick, omit } from "@/lib/pick"; import type { RemoteMediaViewType } from "@/media-view/view-type"; @@ -120,7 +119,7 @@ export type MxSettings = { setScreenshotFormat: ( format: "image/png" | "image/jpeg" | "image/webp", ) => void; - setDefaultLanguage: (lang: Tag | null) => void; + setDefaultLanguage: (lang: string | null) => void; getDefaultLang(): string; setScreenshotQuality: (quality: number | null) => void; setTimestampOffset: (offset: number) => void; @@ -172,7 +171,7 @@ export function createSettingsStore(plugin: MxPlugin) { getDefaultLang() { const userDefaultLang = get().defaultLanguage; const globalDefaultLang = moment.locale(); - if (userDefaultLang && !tag.check(userDefaultLang)) { + if (userDefaultLang && !vaildate(userDefaultLang)) { new Notice( `Invalid language code detected in preferences: ${userDefaultLang}, reverting to ${globalDefaultLang}.`, ); diff --git a/apps/app/src/settings/tab.ts b/apps/app/src/settings/tab.ts index 54b1cd0f..9e7e7446 100644 --- a/apps/app/src/settings/tab.ts +++ b/apps/app/src/settings/tab.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { pathToFileURL } from "url"; import iso from "iso-639-1"; -import tag from "language-tags"; import type { PaneType } from "obsidian"; import { TextComponent, @@ -10,6 +9,7 @@ import { Menu, Platform, } from "obsidian"; +import { getGroupedLangExtra } from "@/lib/lang/lang"; import { showAtButton } from "@/lib/menu"; import { LoginModal } from "@/login/modal"; import type MxPlugin from "@/mx-main"; @@ -326,27 +326,23 @@ export class MxSettingTabs extends PluginSettingTab { ); const fallback = "_follow_"; + const extra = getGroupedLangExtra(); const locales = Object.fromEntries( - iso.getAllCodes().flatMap((code) => { - if (code === "zh") { - return [ - ["zh-Hans", "zh-Hans (简体中文)"], - ["zh-Hant", "zh-Hant (繁體中文)"], - ]; - } - return [[code, `${code} (${iso.getNativeName(code)})`]]; + iso.getAllCodes().flatMap((lang) => { + if (!extra.has(lang)) return [[lang, iso.getNativeName(lang)]]; + return [...extra.get(lang)!.values()]; }), ); new Setting(container) .setName("Default locale") - .setDesc("The default locale for media subtitles") + .setDesc("The default locale for subtitles") .addDropdown((dropdown) => dropdown .addOption(fallback, "Follow obsidian locale") .addOptions(locales) .setValue(this.state.defaultLanguage ?? fallback) .onChange((val) => - this.state.setDefaultLanguage(val === fallback ? null : tag(val)), + this.state.setDefaultLanguage(val === fallback ? null : val), ), ); }