diff --git a/.github/workflows/update_i18n_languages.yml b/.github/workflows/update_i18n_languages.yml index 85606821..eb12f3f1 100644 --- a/.github/workflows/update_i18n_languages.yml +++ b/.github/workflows/update_i18n_languages.yml @@ -48,6 +48,7 @@ jobs: cd master echo "WEBLATE_TOKEN=${{ secrets.MY_SECRET }}" > .env yarn i18n:gen-resources + yarn manga:gen-type-tags - name: Push changes run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index da9058f6..a2f98574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - (**Browse**) Fix missing pinned sources in the source language filter - (**Browse**) Fix incorrectly showing "local source" source in the source language filter (the local source can't be disabled) - (**Reader**) Fix page shift when toggling the "offset double spreads" setting (currently: enable: shift to the right; disable: shift to the left – now: inverted) +- (**Reader**) Fix "auto webtoon mode" detection for manga source languages other than english and the current selected language ## [20251230.01] (r2937) - 2025-12-30 diff --git a/package.json b/package.json index c8eb77e6..a6ae47ee 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dayjs:gen-locales-array": "tsx tools/scripts/dayjs/generateDayJsLocales.ts", "dayjs:gen-locales-import": "tsx tools/scripts/dayjs/generateDayJsLocalesImport.ts", "dayjs:gen-locales": "yarn dayjs:gen-locales-array && yarn dayjs:gen-locales-import", + "manga:gen-type-tags": "tsx tools/scripts/manga/generateMangaTypeTags.ts", "prepare": "husky" }, "engines": { diff --git a/src/features/manga/Manga.constants.ts b/src/features/manga/Manga.constants.ts index 07a58ff8..717fb59e 100644 --- a/src/features/manga/Manga.constants.ts +++ b/src/features/manga/Manga.constants.ts @@ -152,10 +152,124 @@ export const SOURCES_BY_MANGA_TYPE: Record = { ], }; -export const MANGA_TAGS_BY_MANGA_TYPE: Record = { +/** + * Lingui extraction markers — DO NOT remove. + * These `msg` calls ensure the strings stay in .po files for translation. + * The actual matching data is in {@link MANGA_TAGS_BY_MANGA_TYPE} below. + */ +// @ts-ignore - see comment +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const MANGA_TAG_DESCRIPTORS_BY_MANGA_TYPE: Record = { [MangaType.MANGA]: [msg`Manga`], [MangaType.COMIC]: [msg`Comic`], [MangaType.WEBTOON]: [msg`Webtoon`, msg`Long strip`], [MangaType.MANHWA]: [msg`Manhwa`, msg`Long strip`], [MangaType.MANHUA]: [msg`Manhua`, msg`Long strip`], }; + +/** + * All translations of manga type tags across all available locales. + * Used for matching manga genres to detect manga type regardless of source language. + * + * **IMPORTANT:** + * + * This is generated by the `manga:gen-type-tags` script + */ +export const MANGA_TAGS_BY_MANGA_TYPE: Record = { + [MangaType.MANGA]: ['Manga', 'مانگا', 'Mangá', 'Манга', 'மங்கா-', 'Truyện Nhật'], + [MangaType.COMIC]: [ + 'Comic', + 'کمیک', + 'Komik', + 'Fumetti', + 'アメコミ', + '만화', + 'Komiks', + 'Quadrinhos', + 'Комикс', + 'காமிக்', + 'Çizgi roman', + 'Truyện Hoa Kỳ', + '美漫', + ], + [MangaType.WEBTOON]: [ + 'Webtoon', + 'Long strip', + 'ويبتون', + 'وبتون', + 'ウェブトゥーン', + '웹툰', + 'Веб-комикс', + 'வெப்டூன்-', + 'Вебтун', + '条漫', + '條漫', + 'Tira larga', + 'نوار بلند', + 'Bande continue', + 'Függőleges folyamatos', + 'Strip panjang', + 'Striscia lunga', + '縦読み漫画', + '긴 스트립', + 'Długi pasek', + 'Tira longa', + 'Длинная полоска', + 'நீண்ட துண்டு', + 'Uzun şerit', + ], + [MangaType.MANHWA]: [ + 'Manhwa', + 'Long strip', + 'مانهوا', + '韓国マンガ', + '만화', + 'Манхва', + 'மன்அ்வா', + 'Truyện Hàn', + '韩漫', + '韓漫', + 'Tira larga', + 'نوار بلند', + 'Bande continue', + 'Függőleges folyamatos', + 'Strip panjang', + 'Striscia lunga', + '긴 스트립', + 'Długi pasek', + 'Tira longa', + 'Длинная полоска', + 'நீண்ட துண்டு', + 'Uzun şerit', + 'Cuộn dọc', + '条漫', + '條漫', + ], + [MangaType.MANHUA]: [ + 'Manhua', + 'Long strip', + 'مانها', + '漫画', + '만화', + 'Маньхуа', + 'மன்உவா', + 'Truyện Trung', + '國漫', + 'Tira larga', + 'نوار بلند', + 'Bande continue', + 'Függőleges folyamatos', + 'Strip panjang', + 'Striscia lunga', + '縦読み漫画', + '긴 스트립', + 'Długi pasek', + 'Tira longa', + 'Длинная полоска', + 'நீண்ட துண்டு', + 'Uzun şerit', + 'Cuộn dọc', + '条漫', + '條漫', + ], +}; diff --git a/src/features/manga/services/Mangas.ts b/src/features/manga/services/Mangas.ts index dfff9b64..c8ceb8e8 100644 --- a/src/features/manga/services/Mangas.ts +++ b/src/features/manga/services/Mangas.ts @@ -35,7 +35,6 @@ import { MangaGenreInfo, MangaIdInfo, MangaLocationState, - MangaSourceLngInfo, MangaSourceNameInfo, MangaThumbnailInfo, MangaTitleInfo, @@ -53,7 +52,6 @@ import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; import { Confirmation } from '@/base/AppAwaitableComponent.ts'; import { UrlUtil } from '@/lib/UrlUtil.ts'; -import { DEFAULT_LANGUAGE } from '@/lib/ISOLanguageUtil.ts'; type MangaToMigrate = NonNullable; type MangaToMigrateTo = NonNullable['manga']; @@ -630,7 +628,7 @@ export class Mangas { } } - static getType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo): MangaType { + static getType(manga: MangaGenreInfo & MangaSourceNameInfo): MangaType { if (Mangas.isType(manga, MangaType.MANGA)) { return MangaType.MANGA; } @@ -654,35 +652,16 @@ export class Mangas { return MangaType.MANGA; } - static isType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo, type: MangaType): boolean { - const translateMangaTagsByMangaTypeEntries = Object.entries(MANGA_TAGS_BY_MANGA_TYPE).map( - ([mangaType, tags]) => [ - mangaType, - [DEFAULT_LANGUAGE, i18n.locale, manga.source?.lang] - .filter((lng) => !!lng) - .flatMap((language) => - tags.flatMap((tag) => - /* lingui-extract-ignore */ - i18n.t({ ...tag, values: { lng: language } }), - ), - ), - ], - ); - - const translatedMangaTagsByMangaType = Object.fromEntries(translateMangaTagsByMangaTypeEntries) as Record< - string, - string[] - >; - + static isType(manga: MangaGenreInfo & MangaSourceNameInfo, type: MangaType): boolean { const isMatchByGenre = manga.genre.some((genre) => - translatedMangaTagsByMangaType[type].some((tag) => genre.toLowerCase().includes(tag.toLowerCase())), + MANGA_TAGS_BY_MANGA_TYPE[type].some((tag) => genre.toLowerCase().includes(tag.toLowerCase())), ); const isMatchBySource = SOURCES_BY_MANGA_TYPE[type].includes(manga.source?.name.toLowerCase() ?? ''); return isMatchByGenre || isMatchBySource; } - static isLongStripType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo): boolean { + static isLongStripType(manga: MangaGenreInfo & MangaSourceNameInfo): boolean { return ( Mangas.isType(manga, MangaType.WEBTOON) || Mangas.isType(manga, MangaType.MANHWA) || diff --git a/src/features/reader/settings/ReaderSettings.utils.tsx b/src/features/reader/settings/ReaderSettings.utils.tsx index 1b0971d5..fecbe885 100644 --- a/src/features/reader/settings/ReaderSettings.utils.tsx +++ b/src/features/reader/settings/ReaderSettings.utils.tsx @@ -16,7 +16,7 @@ import { ReaderPageScaleMode, ReadingMode, } from '@/features/reader/Reader.types.ts'; -import { MangaGenreInfo, MangaSourceLngInfo, MangaSourceNameInfo } from '@/features/manga/Manga.types.ts'; +import { MangaGenreInfo, MangaSourceNameInfo } from '@/features/manga/Manga.types.ts'; import { Mangas } from '@/features/manga/services/Mangas.ts'; import { ReaderPagedPager } from '@/features/reader/viewer/pager/components/ReaderPagedPager.tsx'; import { ReaderDoublePagedPager } from '@/features/reader/viewer/pager/components/ReaderDoublePagedPager.tsx'; @@ -57,7 +57,7 @@ export const isContinuousVerticalReadingMode = (readingMode: IReaderSettings['re [ReadingMode.CONTINUOUS_VERTICAL, ReadingMode.WEBTOON].includes(readingMode); export const isAutoWebtoonMode = ( - manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo, + manga: MangaGenreInfo & MangaSourceNameInfo, shouldUseAutoWebtoonMode: IReaderSettings['shouldUseAutoWebtoonMode'], readingMode: IReaderSettingsWithDefaultFlag['readingMode'], ): boolean => shouldUseAutoWebtoonMode && readingMode.isDefault && Mangas.isLongStripType(manga); diff --git a/tools/scripts/manga/generateMangaTypeTags.ts b/tools/scripts/manga/generateMangaTypeTags.ts new file mode 100644 index 00000000..f6303485 --- /dev/null +++ b/tools/scripts/manga/generateMangaTypeTags.ts @@ -0,0 +1,99 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import path from 'path'; +import { readdirSync, readFileSync, writeFileSync } from 'fs'; +import { execSync } from 'child_process'; + +const OUTPUT_FILE_PATH = 'src/features/manga/Manga.constants.ts'; +const outputFilePath = path.join(import.meta.dirname, `../../../${OUTPUT_FILE_PATH}`); +const localesDirPath = path.join(import.meta.dirname, '../../../src/i18n/locales'); + +const MANGA_TYPE_TO_MSG_IDS = { + MANGA: ['Manga'], + COMIC: ['Comic'], + WEBTOON: ['Webtoon', 'Long strip'], + MANHWA: ['Manhwa', 'Long strip'], + MANHUA: ['Manhua', 'Long strip'], +} as const satisfies Record; + +type MangaTypeMsgId = (typeof MANGA_TYPE_TO_MSG_IDS)[keyof typeof MANGA_TYPE_TO_MSG_IDS][number]; + +const TARGET_MSG_IDS = new Set(Object.values(MANGA_TYPE_TO_MSG_IDS).flat()); + +const extractTranslations = (poContent: string): Map => + poContent.split('\n').reduce((translations, line, i, lines) => { + const msgIdMatch = line.trim().match(/^msgid "(.+)"$/); + if (!msgIdMatch) { + return translations; + } + + const msgId = msgIdMatch[1]; + if (!TARGET_MSG_IDS.has(msgId as MangaTypeMsgId)) { + return translations; + } + + const msgStrMatch = lines[i + 1]?.trim().match(/^msgstr "(.+)"$/); + if (!msgStrMatch) { + return translations; + } + + return new Map([...translations, [msgId, msgStrMatch[1]]]); + }, new Map()); + +const poFiles = readdirSync(localesDirPath).filter((file) => file.endsWith('.po')); + +const allTranslationsByMsgId = poFiles + .map((poFile) => extractTranslations(readFileSync(path.join(localesDirPath, poFile), 'utf-8'))) + .reduce( + (acc, translations) => + new Map( + [...acc].map(([msgId, existing]) => [ + msgId, + new Set([...existing, ...(translations.has(msgId) ? [translations.get(msgId)!] : [])]), + ]), + ), + new Map([...TARGET_MSG_IDS].map((msgId) => [msgId, new Set()])), + ); + +const translationsByMangaType = Object.fromEntries( + Object.entries(MANGA_TYPE_TO_MSG_IDS).map(([mangaType, msgIds]) => [ + mangaType, + [...new Set([...msgIds, ...msgIds.flatMap((msgId) => [...(allTranslationsByMsgId.get(msgId) ?? [])])])], + ]), +); + +const outputContent = readFileSync(outputFilePath, 'utf-8'); + +const mangaTypes = Object.keys(MANGA_TYPE_TO_MSG_IDS); +const entries = mangaTypes.map((type) => { + const tagList = translationsByMangaType[type].map((tag) => `'${tag.replace(/'/g, "\\'")}'`).join(','); + return ` [MangaType.${type}]: [\n${tagList},\n ]`; +}); + +const newBlock = `export const MANGA_TAGS_BY_MANGA_TYPE: Record = {\n${entries.join(',\n')},\n};`; + +const updatedContent = outputContent.replace( + /export const MANGA_TAGS_BY_MANGA_TYPE: Record = \{[\s\S]*?\n};/, + newBlock, +); + +writeFileSync(outputFilePath, updatedContent); + +console.log('Generated MANGA_TAGS_BY_MANGA_TYPE:'); +Object.entries(translationsByMangaType).forEach(([mangaType, tags]) => { + console.log(` ${mangaType}: ${tags.length} tags — ${tags.join(', ')}`); +}); + +const hasChanges = execSync('git status --porcelain').toString().includes(OUTPUT_FILE_PATH); + +if (hasChanges) { + execSync('git reset'); + execSync(`git add ${OUTPUT_FILE_PATH}`); + execSync('git commit -m "Update manga type tags"'); +}