From f4e5299873ec7ac0b886740eb07902ec3382a9a5 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:09:29 -0400 Subject: [PATCH 01/31] chore: stop tracking .DS_Store and skip generated sdk in prettier The sdk under src/Komga/sdk is regenerated by openapi-ts, so formatting it only creates churn on the next run. --- .DS_Store | Bin 10244 -> 0 bytes .github/.DS_Store | Bin 6148 -> 0 bytes .prettierignore | 4 ++++ src/.DS_Store | Bin 6148 -> 0 bytes src/Komga/sdk/client/.DS_Store | Bin 6148 -> 0 bytes src/paperback-openapi-client/.DS_Store | Bin 6148 -> 0 bytes 6 files changed, 4 insertions(+) delete mode 100644 .DS_Store delete mode 100644 .github/.DS_Store create mode 100644 .prettierignore delete mode 100644 src/.DS_Store delete mode 100644 src/Komga/sdk/client/.DS_Store delete mode 100644 src/paperback-openapi-client/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4d0a9f5012c798ac907f4421e1365260fdf26738..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMZ)_Ar6rZ;(+^%KmwuJ&$kc9?qpoO-yK&g<@JCI7T$n}4Ln?@L=`*2l4rc*U!2U$_}~Oh zkKskYi+~paF9Kc!ya@bPB7om)TKED+y|)(uF9Kc!#v?%Y2N@qqn;e>E)ZRMqWgh{M z<|ErKSeIx8sqD}uho%`t2^L^ok+QDf95H}(N51UnH90iRDC_iBa$EMZk-|#0b!{dp=oC`iVx8S?BkXt2f=UxETyabu)@Qeq(S~!?_C=He}0{ zd&&m~Tz3N%Y3xdTnQ_SgNs(yQ=%nn8i5l)Xp!BJ$1?TA;a_q#-J2RPFD{DTa8Wqt_ z6z5?Oh3G^jR&>}JbUZ#A9n>`|SzUb%xV-#n(|tV87w|hn1Lklj8A+zB=18KCw%Sco zk7nwTXs4zQhoxBo(?~`%LrvHnG(|;~9bKBCn8VFx%1|u3Z-@zaK@h^yypfTbn)>=m zxo&m!Xr(;TP+MIoudJ>g9ToW5>Zy>#6k5Ry(GZ?rv_)VAA~zoPHGOn(7*AbI^2%&h{Q?dKQI_GZ_@ob#YjGkVk># zV!0QF_&XYQfYV6sZ7x7?zPNBGGN;cOH#^Gx2tGY8&ZR4-0IeFbZTK1`sFSlyk9tX!Quy& zRoAawA9%QR+n7{TTrz96DE4BG8%b@CrY1UuH6=OF6R~2dp=d^b*N|puQC)2}B5}0{ zDg|LoDlRIS=P&bjr=r@vl$Ly)m&*CElA>ZsTK63QlCJsZfiGE%7h(FX8z< z$`2$|d^5IK;a@695x(8R`7#q#;tD~sg!a9-5wWhdVjV9jLWdrSsybt>78`hJi_kU1 z!p@kp#dW;UjW@k+O$==$L#gm1kj%*^2;x3;g2gx(!D0!QlA)k;dc|5^f2%oLj+Fa{(^MZRI+- zK5hqdgv)I1y%`@JzKYbzJxnL(Tg9E6ES)yDv}}GvZ2do`)7OP8flBAhT|~30VSV#B z{1{p@0CfZ3#(GwU*jTrLk&9DIv-u0|7v&C226g{rxheT*V)A2DdvUoaOVco2So6UO zY);2iVa>`aS(Z4=P}Z!el|?BJ^G~q*uByT&IE}T9qA2BKCJJNo{blpS`^61{AQ4+J z5JWRHulT=>@bAb?@(Z~Q0wTN&WLN@qunq#yf_UzOov<4s5JgPK!Gu8@*H0j-4?-Fa z!C^%9^N8)2;V8TUufl6^9NvZZYT!m}!72JUDDcW;z zyfqug@Y@|6yT8uCquhyyTJ5j1gL3z`c%sz( q_H{fq=yiMrK2$ez0;BaNhnHp)IavPnKLfn;KVBht=l`jE{{Iu()Rm?H diff --git a/.github/.DS_Store b/.github/.DS_Store deleted file mode 100644 index 5dc8e2d2fac93ad03772580ca1d8e15e1c908a18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKO-@2F5S|eh7&q)-*(-2^N**WR0tAVg_()$c#%(Xcbv&2f{74|y#Dy`&%p~nM zou9VvBXn9K;^oV3K{O?z37Q~_(hxB>T~{7F2C~j^PdycSrRV;kG0|T%$=27Z(Q z)NGN-jbZe`Krj#toEVVvA)yIo$7-lY2ReNM0OcC30$uN{z$6A>cC3c5K-f}&mdgIb zU`vNRd0cj^hL%pOD`OsC`Q!1zeq-jO;l$Z6`d}a!xMW~t!>QE&75+-iNB(6>jDmq+ z;GZ$T<8Iw8@ljq|KYgCm+Jv@26Op(o3Iw{AVxX2{4CEY{tJC~RbojGlHI!MzuIa$I N2q+=Z1p~jpz&oE3FCzc| diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f7db280 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Generated by @hey-api/openapi-ts — reformatting thrashes every regeneration +src/Komga/sdk/ +lib/ +package-lock.json diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index a95903e02a701b1e564bbc3aee25f9d3fe6086d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!EVz)5S?w)CZ$5;K%^d5>XB49ReL}nWJ0(UP8h)fP_W}DwQ75<*ug?kBp;Ak z@ddr|4{+rOAArOc@CkeXGrJolZm3W|MSyms-8Y_{ncaO}?|K1%wMTvnpbh{vEQFOy zSp7h#oyvl&Xv-BuCUdyqpyl*Llq~ofgJHlh@b@u5-|iaR2LTZbp#1%=H}Z08Bsv{0 z6dqb@Z$BKo`F8u$owFYPPdt*txgDZvVK`K0{X-XaOM3|0zP9KwKaQPOA3xuEAjfeg zPb6kzIp3=^9%j>cwjco)OCN^dq1J;V*g=NK63dnVpL-BOAT61fr-|m}y_bYRnpxHt zwz67TTd$&QR-ZeEB6l)38>MY`_>`U=2@!fz-Sr*^{k&VhdRN4m8^ryg3<&%_x;%Rl z#JWy7>}(cwYay zo3xb68mDlA%F>he(_ZM|Bq+(!!KHT$r(b_1$NVb5_}B0DhZ_fXrsnDi#AVZbo(4>Lgf1II#WYb+GXtph801wgEzSqkcO zm!KG~(biZf#1<5$LJ?Ic(^m|p!qLvPpRKV_sKSBi%LmgvGkrs0vUkkSr8}q-UFoJa z3>XGx46G`(PUru<^Y8z&0HYZO3tS+3yS#>kTjUeFz`ni_zuk2+RXp} diff --git a/src/Komga/sdk/client/.DS_Store b/src/Komga/sdk/client/.DS_Store deleted file mode 100644 index 23a9753e6a2e2a3ce1ec84dc0828ce5bf6df35b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%TB^T6g`8)K-{o&*&k4YapAI(x--Ot=o%;$(Uvw8jPCjeewM%Gxicf70o-Yf zxi`6U=Dy}}+H^VqOm8*m0v!M?s$iqdW`{^zbW0lEb4WBYMmp^d;yf$1GS%@{8IWh! zM2s8@B=zTa{=ON@@+cao`ItU=87_m1kE?oo@@>3$+;3otdXZYnfHLr}7%3$+;3otdXZYnfHLr}7% Date: Thu, 3 Sep 2026 00:09:49 -0400 Subject: [PATCH 02/31] fix(Komga): guard optional headers in image interceptor Request.headers is optional, so the non-null assertions threw a TypeError on any request that arrived without headers. --- src/Komga/interceptors/image_interceptor.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Komga/interceptors/image_interceptor.ts b/src/Komga/interceptors/image_interceptor.ts index 6dd45d4..40e73ac 100644 --- a/src/Komga/interceptors/image_interceptor.ts +++ b/src/Komga/interceptors/image_interceptor.ts @@ -7,13 +7,16 @@ import { getKomgaCredentials } from '../utils/config.js' export class KomgaImageInterceptor extends PaperbackInterceptor { override async interceptRequest(request: Request): Promise { + // `headers` is optional on Request + const headers = (request.headers ??= {}) + // Normally auth is handled by the sdk. We only want to inject headers if they're not present - if (request.headers!['Authorization'] !== undefined) { + if (headers['Authorization'] !== undefined) { return request } const { username, password } = getKomgaCredentials() - request.headers!['Authorization'] = 'Basic ' + btoa(`${username}:${password}`) + headers['Authorization'] = 'Basic ' + btoa(`${username}:${password}`) return request } From 5baccedd3ebb4bdd0865fa7c6b1cee15c6ff4cbd Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:10:33 -0400 Subject: [PATCH 03/31] fix(Komga): clean up chapter titles and recover volumes The old prefix strip only matched the literal word "chapter" followed by a space, so abbreviated and volume-prefixed titles were left intact and shown twice, e.g. "Ch. 245 - Ch.245" or "Ch. 74.5 - Vol.13 Ch.074.5". Across the test library that was 1645 of 14633 books. parseChapterTitle peels leading volume/chapter/separator tokens in a loop, which also covers doubled prefixes like "Chapter 10 - Chapter 10", and pulls out the volume when the source recorded one. Volume placeholders (TBD, TBA) are stripped without setting a volume. Drops the hardcoded volume: 0 and the file size that was being passed as langCode. --- src/Komga/komga.ts | 19 ++++++--- src/Komga/utils/titles.ts | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/Komga/utils/titles.ts diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 51d33ea..742a6bb 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -61,6 +61,7 @@ import { getShowOnDeck, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' +import { parseChapterTitle } from './utils/titles.js' import { ProgressManagementForm } from './forms/progress_management_form.js' import type KomgaConfig from './pbconfig.js' @@ -458,16 +459,24 @@ export class KomgaExtension implements ExtensionImpl { sourceManga.mangaInfo.additionalInfo?.['language']?.toUpperCase() ?? 'UNKNOWN' for (const book of booksResult.content ?? []) { + // Komga has no volume field on a book, it is embedded in the title + const { title, volume } = parseChapterTitle( + book.metadata.title, + book.metadata.number + ) + chapters.push({ chapterId: book.id, chapNum: parseFloat(book.metadata.number), - volume: 0, - langCode: book.size, - title: (book.metadata.title ?? '').replace(/^chapter\s+[\d.]+[:\s-]*/i, '').trim(), - publishDate: book.metadata.releaseDate ? new Date(book.metadata.releaseDate) : new Date(book.fileLastModified), + langCode: languageCode, + // An unset volume renders as `Vol. TBA`, 0 hides the segment + title: title ?? '', + volume: volume ?? 0, + publishDate: book.metadata.releaseDate + ? new Date(book.metadata.releaseDate) + : new Date(book.fileLastModified), sortingIndex: book.metadata.numberSort, sourceManga: sourceManga, - version: languageCode, }) } diff --git a/src/Komga/utils/titles.ts b/src/Komga/utils/titles.ts new file mode 100644 index 0000000..20d6356 --- /dev/null +++ b/src/Komga/utils/titles.ts @@ -0,0 +1,82 @@ +// Komga has no volume field on a book: `BookMetadataDto` carries only `number`, +// `numberSort` and a free-form `title`. Scanners encode the volume, the chapter, +// or both into that title, in shapes like: +// +// 'Chapter 254' 'Ch.234' 'Ch. 1181' 'Ch.040.5' 'Chapter -1' +// 'Vol.13 Ch.067' 'Vol.TBD Ch.234' 'Vol.02 Ch.016 - The Aria' +// +// Paperback already renders the chapter number, so a leading volume or chapter +// token in the title shows up twice on screen. + +// `Vol.13`, `Vol. 4`, `Volume 2`, and placeholders like `Vol.TBD` / `Vol.???`. +// `Vol` is matched case-insensitively through character classes so the +// placeholder branch can stay upper-case only - an /i flag there would let it +// swallow the first word of a real title such as `Vol. the Beginning`. +const VOLUME_PREFIX = + /^\s*[Vv][Oo][Ll](?:[Uu][Mm][Ee])?\.?\s*(\d+(?:\.\d+)*|[A-Z?]{2,5}(?![A-Za-z]))\s*/ + +// `Chapter 254`, `Ch.234`, `Ch. 1181`, `Ch.040.5`, `Chapter -1` +const CHAPTER_PREFIX = /^\s*ch(?:apter)?\.?\s*(-?\d+(?:\.\d+)*)\s*/i + +const SEPARATOR = /^\s*[-–—:|]+\s*/ + +const NUMERIC_ONLY = /^-?\d+(?:\.\d+)*$/ + +export interface ParsedChapterTitle { + title?: string + // Only set when the source encoded a numeric volume. Callers should map + // undefined onto `volume: 0`, which is what hides Paperback's volume segment. + volume?: number +} + +export function parseChapterTitle( + rawTitle: string | undefined, + chapterNumber?: string +): ParsedChapterTitle { + let rest = (rawTitle ?? '').trim() + let volume: number | undefined + + // Loop so doubled prefixes (`Chapter 10 - Chapter 10`) and combined ones + // (`Vol.13 Ch.067`) are both handled + for (;;) { + const volumeMatch = VOLUME_PREFIX.exec(rest) + if (volumeMatch) { + // TBD/TBA/? are placeholders, strip them without recording a volume + const parsed = Number.parseFloat(volumeMatch[1] ?? '') + if (volume === undefined && Number.isFinite(parsed)) { + volume = parsed + } + rest = rest.slice(volumeMatch[0].length) + continue + } + + const chapterMatch = CHAPTER_PREFIX.exec(rest) + if (chapterMatch) { + rest = rest.slice(chapterMatch[0].length) + continue + } + + const separatorMatch = SEPARATOR.exec(rest) + if (separatorMatch) { + rest = rest.slice(separatorMatch[0].length) + continue + } + + break + } + + rest = rest.trim() + + // A bare restatement of the chapter number adds nothing. The remainder has to + // be entirely numeric: parseFloat alone reads "1F.Headon's Floor" as 1 and + // would drop a real title. + if ( + chapterNumber !== undefined && + NUMERIC_ONLY.test(rest) && + Number.parseFloat(rest) === Number.parseFloat(chapterNumber) + ) { + rest = '' + } + + return { title: rest === '' ? undefined : rest, volume } +} From 1cc8b4da0456e20ac70ab7a597038f71991490e5 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:10:58 -0400 Subject: [PATCH 04/31] fix(Komga): fill in series metadata that was being dropped contentRating was pinned to EVERYONE, so 18+ series were labelled safe. Komga exposes a minimum age in metadata.ageRating. alternateTitles and readingDirection were also available and unused; readingDirection is what the reader needs to pick long strip mode. --- src/Komga/komga.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 742a6bb..701e372 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -81,6 +81,20 @@ export const capitalize = (tag: string): string => { return tag.replace(/^\w/, (c) => c.toUpperCase()) } +// Komga stores an age rating as a minimum age +export const parseContentRating = (ageRating?: number): ContentRating => { + if (ageRating === undefined) { + return ContentRating.EVERYONE + } + if (ageRating >= 18) { + return ContentRating.ADULT + } + if (ageRating >= 16) { + return ContentRating.MATURE + } + return ContentRating.EVERYONE +} + export class KomgaExtension implements ExtensionImpl { async getAdvancedSearchForm( query: SearchQuery @@ -229,8 +243,8 @@ export class KomgaExtension implements ExtensionImpl { mangaInfo: { thumbnailUrl: thumbnailUrl, primaryTitle: metadata.title, - secondaryTitles: [], - contentRating: ContentRating.EVERYONE, + secondaryTitles: metadata.alternateTitles.map((alt) => alt.title), + contentRating: parseContentRating(metadata.ageRating), status: parseMangaStatus(metadata.status), artist: artists.join(', '), author: authors.join(', '), @@ -238,6 +252,8 @@ export class KomgaExtension implements ExtensionImpl { tagGroups: tagSections, additionalInfo: { language: metadata.language, + readingDirection: metadata.readingDirection, + publisher: metadata.publisher, }, }, } From 1b83c7f7b9892af5e64e8a09cc71cb337cd5eb72 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:11:06 -0400 Subject: [PATCH 05/31] fix(Komga): don't let getSearchFilters take down the homepage The comment above it already said this must not throw, but each of the four lookups threw on failure, so an unconfigured or offline server broke the homepage. They now fall back to empty option lists and run in parallel. --- src/Komga/komga.ts | 46 +++++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 701e372..97e39c8 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -268,26 +268,23 @@ export class KomgaExtension implements ExtensionImpl { // - `library` // To be able to make the difference between theses types, we append `genre-` or `tag-` at the beginning of the tag id - const { data: genresResult, error: genresError } = await getGenres() - if (!genresResult) { - throw new Error(JSON.stringify(genresError, undefined, 2)) - } - - const { data: tagsResult, error: tagsError } = await getSeriesTags() - if (!tagsResult) { - throw new Error(JSON.stringify(tagsError, undefined, 2)) - } - - const { data: collectionResult, error: collectionError } = - await getCollections() - if (!collectionResult) { - throw new Error(JSON.stringify(collectionError, undefined, 2)) - } - - const { data: libraryResult, error: libraryError } = await getLibraries() - if (!libraryResult) { - throw new Error(JSON.stringify(libraryError, undefined, 2)) - } + // Each lookup falls back to an empty list so an unreachable server does not + // take down the homepage + const [genresResult, tagsResult, collectionResult, libraryResult] = + await Promise.all([ + getGenres() + .then((r) => r.data ?? []) + .catch(() => []), + getSeriesTags() + .then((r) => r.data ?? []) + .catch(() => []), + getCollections() + .then((r) => r.data?.content ?? []) + .catch(() => []), + getLibraries() + .then((r) => r.data ?? []) + .catch(() => []), + ]) const genreSearchFilter: SearchFilter = { type: 'multiselect', @@ -324,11 +321,10 @@ export class KomgaExtension implements ExtensionImpl { id: 'collections', title: 'Collections', maximum: undefined, - options: - collectionResult.content?.map((elem) => ({ - id: 'collection-' + btoa(elem.id), - value: capitalize(elem.name), - })) ?? [], + options: collectionResult.map((elem) => ({ + id: 'collection-' + btoa(elem.id), + value: capitalize(elem.name), + })), value: {}, } From 7bdb04fb62cc90155ca58c45b74e4906800af97f Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:11:12 -0400 Subject: [PATCH 06/31] fix(Komga): stop url-encoding search filter values These go into a JSON body, not a query string, so a genre like "Science Fiction" was sent as "Science%20Fiction" and matched nothing. Any tag or genre containing a space or ampersand returned no results. --- src/Komga/komga.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 97e39c8..7e62718 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -376,25 +376,24 @@ export class KomgaExtension implements ExtensionImpl { } const operator = filterValue == 'included' ? 'is' : 'isNot' - console.log(key) // There are two types of tags: `tag` and `genre` if (key.substring(0, 4) == 'tag-') { - const tag = encodeURIComponent(atob(key.substring(4))) + const tag = atob(key.substring(4)) filters.push({ tag: { operator, value: tag } }) } if (key.substring(0, 6) == 'genre-') { - const genre = encodeURIComponent(atob(key.substring(6))) + const genre = atob(key.substring(6)) filters.push({ genre: { operator, value: genre } }) } if (key.substring(0, 11) == 'collection-') { - const collectionId = encodeURIComponent(atob(key.substring(11))) + const collectionId = atob(key.substring(11)) filters.push({ collectionId: { operator, value: collectionId } }) } if (key.substring(0, 8) == 'library-') { - const libraryId = encodeURIComponent(atob(key.substring(8))) + const libraryId = atob(key.substring(8)) filters.push({ libraryId: { operator, value: libraryId } }) } } From 20b6b9ae49f564b3614ef78fe6026835ff7d1cc6 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:11:26 -0400 Subject: [PATCH 07/31] style: format sources with prettier These files predated the prettier config and were never run through it. Formatting only, no behaviour change. --- src/Komga/forms/settings_form.ts | 13 +- src/Komga/main.ts | 4 +- src/Komga/utils.ts | 2 +- src/Komga/utils/config.ts | 18 +- src/Komga_2/main.ts | 4 +- src/Komga_2/pbconfig.ts | 4 +- src/paperback-openapi-client/core/auth.ts | 25 +- .../core/bodySerializer.ts | 62 ++--- src/paperback-openapi-client/core/params.ts | 102 ++++---- .../core/pathSerializer.ts | 119 +++++---- src/paperback-openapi-client/core/types.ts | 44 ++-- src/paperback-openapi-client/index.ts | 12 +- src/paperback-openapi-client/plugin.ts | 12 +- src/paperback-openapi-client/types.ts | 129 +++++----- src/paperback-openapi-client/utils.ts | 230 +++++++++--------- src/tests/Komga.ts | 21 +- src/tests/Komga_2.ts | 21 +- 17 files changed, 410 insertions(+), 412 deletions(-) diff --git a/src/Komga/forms/settings_form.ts b/src/Komga/forms/settings_form.ts index d2917d7..4afa2ea 100644 --- a/src/Komga/forms/settings_form.ts +++ b/src/Komga/forms/settings_form.ts @@ -1,4 +1,9 @@ -import { Form, NavigationRow, Section, type FormSectionElement } from '@paperback/types' +import { + Form, + NavigationRow, + Section, + type FormSectionElement, +} from '@paperback/types' import { ServerSettingsForm } from './server_settings_form.js' import { HomepageSettingsForm } from './homepage_settings_form.js' @@ -9,14 +14,14 @@ export class SettingsForm extends Form { NavigationRow('authentication', { title: 'Server Settings', form: new ServerSettingsForm(), - }), + }), ]), - + Section('homepageSettings', [ NavigationRow('authentication', { title: 'Homepage Settings', form: new HomepageSettingsForm(), - }), + }), ]), ] } diff --git a/src/Komga/main.ts b/src/Komga/main.ts index c3d73fc..c58cbf8 100644 --- a/src/Komga/main.ts +++ b/src/Komga/main.ts @@ -1,3 +1,3 @@ -import { KomgaExtension } from "./komga.js"; +import { KomgaExtension } from './komga.js' -export const Komga = new KomgaExtension() \ No newline at end of file +export const Komga = new KomgaExtension() diff --git a/src/Komga/utils.ts b/src/Komga/utils.ts index c15b7a5..53a37ca 100644 --- a/src/Komga/utils.ts +++ b/src/Komga/utils.ts @@ -20,4 +20,4 @@ export function isEqualTo(value: T): Is { export function isNotEqualTo(value: T): IsNot { return Operator({ operator: 'isNot', value: value }) -} \ No newline at end of file +} diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 409a7fe..6a523a6 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -11,7 +11,7 @@ const DEFAULT_SHOW_ON_DECK = true const DEFAULT_SHOW_CONTINUE_READING = true function getStateOrDefault(key: string, def: T): T { - return Application.getState(key) as T ?? def + return (Application.getState(key) as T) ?? def } export function getKomgaBaseURL() { @@ -24,8 +24,12 @@ export function setKomgaBaseURL(url: string) { export function getKomgaCredentials() { return { - username: Application.getSecureState(KEY_KOMGA_USERNAME) as string ?? DEFAULT_KOMGA_USERNAME, - password: Application.getSecureState(KEY_KOMGA_PASSWORD) as string ?? DEFAULT_KOMGA_PASSWORD, + username: + (Application.getSecureState(KEY_KOMGA_USERNAME) as string) ?? + DEFAULT_KOMGA_USERNAME, + password: + (Application.getSecureState(KEY_KOMGA_PASSWORD) as string) ?? + DEFAULT_KOMGA_PASSWORD, } } @@ -34,7 +38,6 @@ export function setKomgaCredentials(username: string, password: string) { Application.setSecureState(password, KEY_KOMGA_PASSWORD) } - export function getShowOnDeck() { return getStateOrDefault(KEY_SHOW_ON_DECK, DEFAULT_SHOW_ON_DECK) } @@ -44,9 +47,12 @@ export function setShowOnDeck(newValue: boolean) { } export function getShowContinueReading() { - return getStateOrDefault(KEY_SHOW_CONTINUE_READING, DEFAULT_SHOW_CONTINUE_READING) + return getStateOrDefault( + KEY_SHOW_CONTINUE_READING, + DEFAULT_SHOW_CONTINUE_READING + ) } export function setShowContinueReading(newValue: boolean) { Application.setState(newValue, KEY_SHOW_CONTINUE_READING) -} \ No newline at end of file +} diff --git a/src/Komga_2/main.ts b/src/Komga_2/main.ts index caf00fb..2d12429 100644 --- a/src/Komga_2/main.ts +++ b/src/Komga_2/main.ts @@ -1,3 +1,3 @@ -import { KomgaExtension } from "../Komga/komga.js"; +import { KomgaExtension } from '../Komga/komga.js' -export const Komga_2 = new KomgaExtension() \ No newline at end of file +export const Komga_2 = new KomgaExtension() diff --git a/src/Komga_2/pbconfig.ts b/src/Komga_2/pbconfig.ts index 4e523a4..bed76e5 100644 --- a/src/Komga_2/pbconfig.ts +++ b/src/Komga_2/pbconfig.ts @@ -1,6 +1,4 @@ -import { - type ExtensionInfo, -} from '@paperback/types' +import { type ExtensionInfo } from '@paperback/types' import KomgaConfig from '../Komga/pbconfig.js' diff --git a/src/paperback-openapi-client/core/auth.ts b/src/paperback-openapi-client/core/auth.ts index d3c56ed..df27d50 100644 --- a/src/paperback-openapi-client/core/auth.ts +++ b/src/paperback-openapi-client/core/auth.ts @@ -1,4 +1,4 @@ -export type AuthToken = string | undefined; +export type AuthToken = string | undefined export interface Auth { /** @@ -6,35 +6,34 @@ export interface Auth { * * @default 'header' */ - in?: 'header' | 'query' | 'cookie'; + in?: 'header' | 'query' | 'cookie' /** * Header or query parameter name. * * @default 'Authorization' */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; + name?: string + scheme?: 'basic' | 'bearer' + type: 'apiKey' | 'http' } export const getAuthToken = async ( auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken ): Promise => { - const token = - typeof callback === 'function' ? await callback(auth) : callback; + const token = typeof callback === 'function' ? await callback(auth) : callback if (!token) { - return; + return } if (auth.scheme === 'bearer') { - return `Bearer ${token}`; + return `Bearer ${token}` } if (auth.scheme === 'basic') { - return `Basic ${Application.base64Encode(token)}`; + return `Basic ${Application.base64Encode(token)}` } - return token; -}; + return token +} diff --git a/src/paperback-openapi-client/core/bodySerializer.ts b/src/paperback-openapi-client/core/bodySerializer.ts index 212c89a..2f4d518 100644 --- a/src/paperback-openapi-client/core/bodySerializer.ts +++ b/src/paperback-openapi-client/core/bodySerializer.ts @@ -3,83 +3,83 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions, -} from './pathSerializer.js'; +} from './pathSerializer.js' -export type QuerySerializer = (query: Record) => string; +export type QuerySerializer = (query: Record) => string -export type BodySerializer = (body: any) => any; +export type BodySerializer = (body: any) => any export interface QuerySerializerOptions { - allowReserved?: boolean; - array?: SerializerOptions; - object?: SerializerOptions; + allowReserved?: boolean + array?: SerializerOptions + object?: SerializerOptions } const serializeFormDataPair = (data: FormData, key: string, value: unknown) => { if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); + data.append(key, value) } else { - data.append(key, JSON.stringify(value)); + data.append(key, JSON.stringify(value)) } -}; +} const serializeUrlSearchParamsPair = ( data: URLSearchParams, key: string, - value: unknown, + value: unknown ) => { if (typeof value === 'string') { - data.append(key, value); + data.append(key, value) } else { - data.append(key, JSON.stringify(value)); + data.append(key, JSON.stringify(value)) } -}; +} export const formDataBodySerializer = { bodySerializer: | Array>>( - body: T, + body: T ) => { - const data = new FormData(); + const data = new FormData() Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return; + return } if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); + value.forEach((v) => serializeFormDataPair(data, key, v)) } else { - serializeFormDataPair(data, key, value); + serializeFormDataPair(data, key, value) } - }); + }) - return data; + return data }, -}; +} export const jsonBodySerializer = { bodySerializer: (body: T) => JSON.stringify(body, (_key, value) => - typeof value === 'bigint' ? value.toString() : value, + typeof value === 'bigint' ? value.toString() : value ), -}; +} export const urlSearchParamsBodySerializer = { bodySerializer: | Array>>( - body: T, + body: T ) => { - const data = new URLSearchParams(); + const data = new URLSearchParams() Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return; + return } if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) } else { - serializeUrlSearchParamsPair(data, key, value); + serializeUrlSearchParamsPair(data, key, value) } - }); + }) - return data.toString(); + return data.toString() }, -}; +} diff --git a/src/paperback-openapi-client/core/params.ts b/src/paperback-openapi-client/core/params.ts index 7559bbb..26f0498 100644 --- a/src/paperback-openapi-client/core/params.ts +++ b/src/paperback-openapi-client/core/params.ts @@ -1,43 +1,43 @@ -type Slot = 'body' | 'headers' | 'path' | 'query'; +type Slot = 'body' | 'headers' | 'path' | 'query' export type Field = | { - in: Exclude; - key: string; - map?: string; + in: Exclude + key: string + map?: string } | { - in: Extract; - key?: string; - map?: string; - }; + in: Extract + key?: string + map?: string + } export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; + allowExtra?: Partial> + args?: ReadonlyArray } -export type FieldsConfig = ReadonlyArray; +export type FieldsConfig = ReadonlyArray const extraPrefixesMap: Record = { $body_: 'body', $headers_: 'headers', $path_: 'path', $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); +} +const extraPrefixes = Object.entries(extraPrefixesMap) type KeyMap = Map< string, { - in: Slot; - map?: string; + in: Slot + map?: string } ->; +> const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { if (!map) { - map = new Map(); + map = new Map() } for (const config of fields) { @@ -46,87 +46,85 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { map.set(config.key, { in: config.in, map: config.map, - }); + }) } } else if (config.args) { - buildKeyMap(config.args, map); + buildKeyMap(config.args, map) } } - return map; -}; + return map +} interface Params { - body: unknown; - headers: Record; - path: Record; - query: Record; + body: unknown + headers: Record + path: Record + query: Record } const stripEmptySlots = (params: Params) => { for (const [slot, value] of Object.entries(params)) { if (value && typeof value === 'object' && !Object.keys(value).length) { - delete params[slot as Slot]; + delete params[slot as Slot] } } -}; +} export const buildClientParams = ( args: ReadonlyArray, - fields: FieldsConfig, + fields: FieldsConfig ) => { const params: Params = { body: {}, headers: {}, path: {}, query: {}, - }; + } - const map = buildKeyMap(fields); + const map = buildKeyMap(fields) - let config: FieldsConfig[number] | undefined; + let config: FieldsConfig[number] | undefined for (const [index, arg] of args.entries()) { if (fields[index]) { - config = fields[index]; + config = fields[index] } if (!config) { - continue; + continue } if ('in' in config) { if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - (params[field.in] as Record)[name] = arg; + const field = map.get(config.key)! + const name = field.map || config.key + ;(params[field.in] as Record)[name] = arg } else { - params.body = arg; + params.body = arg } } else { for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); + const field = map.get(key) if (field) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; + const name = field.map || key + ;(params[field.in] as Record)[name] = value } else { - const extra = extraPrefixes.find(([prefix]) => - key.startsWith(prefix), - ); + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) if (extra) { - const [prefix, slot] = extra; - (params[slot] as Record)[ + const [prefix, slot] = extra + ;(params[slot] as Record)[ key.slice(prefix.length) - ] = value; + ] = value } else { for (const [slot, allowed] of Object.entries( - config.allowExtra ?? {}, + config.allowExtra ?? {} )) { if (allowed) { - (params[slot as Slot] as Record)[key] = value; - break; + ;(params[slot as Slot] as Record)[key] = value + break } } } @@ -135,7 +133,7 @@ export const buildClientParams = ( } } - stripEmptySlots(params); + stripEmptySlots(params) - return params; -}; + return params +} diff --git a/src/paperback-openapi-client/core/pathSerializer.ts b/src/paperback-openapi-client/core/pathSerializer.ts index d692cf0..66f2e00 100644 --- a/src/paperback-openapi-client/core/pathSerializer.ts +++ b/src/paperback-openapi-client/core/pathSerializer.ts @@ -1,68 +1,67 @@ interface SerializeOptions - extends SerializePrimitiveOptions, - SerializerOptions {} + extends SerializePrimitiveOptions, SerializerOptions {} interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; + allowReserved?: boolean + name: string } export interface SerializerOptions { /** * @default true */ - explode: boolean; - style: T; + explode: boolean + style: T } -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited' +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle +type MatrixStyle = 'label' | 'matrix' | 'simple' +export type ObjectStyle = 'form' | 'deepObject' +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; + value: string } export const separatorArrayExplode = (style: ArraySeparatorStyle) => { switch (style) { case 'label': - return '.'; + return '.' case 'matrix': - return ';'; + return ';' case 'simple': - return ','; + return ',' default: - return '&'; + return '&' } -}; +} export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { switch (style) { case 'form': - return ','; + return ',' case 'pipeDelimited': - return '|'; + return '|' case 'spaceDelimited': - return '%20'; + return '%20' default: - return ','; + return ',' } -}; +} export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { switch (style) { case 'label': - return '.'; + return '.' case 'matrix': - return ';'; + return ';' case 'simple': - return ','; + return ',' default: - return '&'; + return '&' } -}; +} export const serializeArrayParam = ({ allowReserved, @@ -71,42 +70,42 @@ export const serializeArrayParam = ({ style, value, }: SerializeOptions & { - value: unknown[]; + value: unknown[] }) => { if (!explode) { const joinedValues = ( allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); + ).join(separatorArrayNoExplode(style)) switch (style) { case 'label': - return `.${joinedValues}`; + return `.${joinedValues}` case 'matrix': - return `;${name}=${joinedValues}`; + return `;${name}=${joinedValues}` case 'simple': - return joinedValues; + return joinedValues default: - return `${name}=${joinedValues}`; + return `${name}=${joinedValues}` } } - const separator = separatorArrayExplode(style); + const separator = separatorArrayExplode(style) const joinedValues = value .map((v) => { if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); + return allowReserved ? v : encodeURIComponent(v as string) } return serializePrimitiveParam({ allowReserved, name, value: v as string, - }); + }) }) - .join(separator); + .join(separator) return style === 'label' || style === 'matrix' ? separator + joinedValues - : joinedValues; -}; + : joinedValues +} export const serializePrimitiveParam = ({ allowReserved, @@ -114,17 +113,17 @@ export const serializePrimitiveParam = ({ value, }: SerializePrimitiveParam) => { if (value === undefined || value === null) { - return ''; + return '' } if (typeof value === 'object') { throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.' + ) } - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; + return `${name}=${allowReserved ? value : encodeURIComponent(value)}` +} export const serializeObjectParam = ({ allowReserved, @@ -134,46 +133,46 @@ export const serializeObjectParam = ({ value, valueOnly, }: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; + value: Record | Date + valueOnly?: boolean }) => { if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` } if (style !== 'deepObject' && !explode) { - let values: string[] = []; + let values: string[] = [] Object.entries(value).forEach(([key, v]) => { values = [ ...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string), - ]; - }); - const joinedValues = values.join(','); + ] + }) + const joinedValues = values.join(',') switch (style) { case 'form': - return `${name}=${joinedValues}`; + return `${name}=${joinedValues}` case 'label': - return `.${joinedValues}`; + return `.${joinedValues}` case 'matrix': - return `;${name}=${joinedValues}`; + return `;${name}=${joinedValues}` default: - return joinedValues; + return joinedValues } } - const separator = separatorObjectExplode(style); + const separator = separatorObjectExplode(style) const joinedValues = Object.entries(value) .map(([key, v]) => serializePrimitiveParam({ allowReserved, name: style === 'deepObject' ? `${name}[${key}]` : key, value: v as string, - }), + }) ) - .join(separator); + .join(separator) return style === 'label' || style === 'matrix' ? separator + joinedValues - : joinedValues; -}; + : joinedValues +} diff --git a/src/paperback-openapi-client/core/types.ts b/src/paperback-openapi-client/core/types.ts index 3df333c..5aca333 100644 --- a/src/paperback-openapi-client/core/types.ts +++ b/src/paperback-openapi-client/core/types.ts @@ -1,9 +1,9 @@ -import type { Auth, AuthToken } from './auth.js'; +import type { Auth, AuthToken } from './auth.js' import type { BodySerializer, QuerySerializer, QuerySerializerOptions, -} from './bodySerializer.js'; +} from './bodySerializer.js' export interface Client< RequestFn = never, @@ -14,19 +14,19 @@ export interface Client< /** * Returns the final request URL. */ - buildUrl: BuildUrlFn; - connect: MethodFn; - delete: MethodFn; - get: MethodFn; - getConfig: () => Config; - head: MethodFn; - options: MethodFn; - patch: MethodFn; - post: MethodFn; - put: MethodFn; - request: RequestFn; - setConfig: (config: Config) => Config; - trace: MethodFn; + buildUrl: BuildUrlFn + connect: MethodFn + delete: MethodFn + get: MethodFn + getConfig: () => Config + head: MethodFn + options: MethodFn + patch: MethodFn + post: MethodFn + put: MethodFn + request: RequestFn + setConfig: (config: Config) => Config + trace: MethodFn } export interface Config { @@ -34,12 +34,12 @@ export interface Config { * Auth token or a function returning auth token. The resolved value will be * added to the request payload as defined by its `security` array. */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken /** * A function for serializing request body parameter. By default, * {@link JSON.stringify()} will be used. */ - bodySerializer?: BodySerializer | null; + bodySerializer?: BodySerializer | null /** * An object containing any HTTP headers that you want to pre-populate your * `Headers` object with. @@ -57,7 +57,7 @@ export interface Config { | null | undefined | unknown - >; + > /** * The request method. * @@ -72,7 +72,7 @@ export interface Config { | 'PATCH' | 'POST' | 'PUT' - | 'TRACE'; + | 'TRACE' /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -83,16 +83,16 @@ export interface Config { * * {@link https://swagger.io/docs/specification/serialization/#query View examples} */ - querySerializer?: QuerySerializer | QuerySerializerOptions; + querySerializer?: QuerySerializer | QuerySerializerOptions /** * A function transforming response data before it's returned. This is useful * for post-processing data, e.g. converting ISO strings into Date objects. */ - responseTransformer?: (data: unknown) => Promise; + responseTransformer?: (data: unknown) => Promise /** * A function validating response data. This is useful if you want to ensure * the response conforms to the desired shape, so it can be safely passed to * the transformers and returned to the user. */ - responseValidator?: (data: unknown) => Promise; + responseValidator?: (data: unknown) => Promise } diff --git a/src/paperback-openapi-client/index.ts b/src/paperback-openapi-client/index.ts index c34b8f2..bd5a10a 100644 --- a/src/paperback-openapi-client/index.ts +++ b/src/paperback-openapi-client/index.ts @@ -1,11 +1,11 @@ -export { createClient } from './client.js'; -export type { Auth } from './core/auth.js'; -export type { QuerySerializerOptions } from './core/bodySerializer.js'; +export { createClient } from './client.js' +export type { Auth } from './core/auth.js' +export type { QuerySerializerOptions } from './core/bodySerializer.js' export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from './core/bodySerializer.js'; +} from './core/bodySerializer.js' export type { Client, ClientOptions, @@ -16,5 +16,5 @@ export type { RequestOptions, RequestResult, TDataShape, -} from './types.js'; -export { createConfig } from './utils.js'; +} from './types.js' +export { createConfig } from './utils.js' diff --git a/src/paperback-openapi-client/plugin.ts b/src/paperback-openapi-client/plugin.ts index 4bf5c2e..846c8ef 100644 --- a/src/paperback-openapi-client/plugin.ts +++ b/src/paperback-openapi-client/plugin.ts @@ -5,25 +5,25 @@ import { clientPluginHandler, type DefinePlugin, definePluginConfig, -} from '@hey-api/openapi-ts'; +} from '@hey-api/openapi-ts' type Config = Client.Config & { /** * Plugin name. Must be unique. */ - name: string; -}; + name: string +} -export type PaperbackClient = DefinePlugin; +export type PaperbackClient = DefinePlugin export const defaultConfig: PaperbackClient['Config'] = { ...clientDefaultMeta, config: clientDefaultConfig, handler: clientPluginHandler as PaperbackClient['Handler'], name: import.meta.filename, -}; +} /** * Type helper for `my-client` plugin, returns {@link Plugin.Config} object */ -export const paperbackClientPlugin = definePluginConfig(defaultConfig); \ No newline at end of file +export const paperbackClientPlugin = definePluginConfig(defaultConfig) diff --git a/src/paperback-openapi-client/types.ts b/src/paperback-openapi-client/types.ts index 61aac3c..2e243cc 100644 --- a/src/paperback-openapi-client/types.ts +++ b/src/paperback-openapi-client/types.ts @@ -1,25 +1,27 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Auth } from './core/auth.js'; -import type { Client as CoreClient, Config as CoreConfig } from './core/types.js'; +import type { Auth } from './core/auth.js' +import type { + Client as CoreClient, + Config as CoreConfig, +} from './core/types.js' import type { Headers } from './Headers+Disposable.js' -import type { Middleware } from './utils.js'; +import type { Middleware } from './utils.js' import type { Request, Response } from '@paperback/types' export interface Config - extends Omit, - CoreConfig { + extends Omit, CoreConfig { /** * Base URL for all requests made by this client. */ - baseUrl?: T['baseUrl']; + baseUrl?: T['baseUrl'] /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: (request: Request) => ReturnType; + fetch?: (request: Request) => ReturnType /** * Return the response data parsed in a specified format. By default, `auto` * will infer the appropriate method from the `Content-Type` response header. @@ -27,37 +29,34 @@ export interface Config * * @default 'json' */ - parseAs?: - | 'arrayBuffer' - | 'json' - | 'text'; + parseAs?: 'arrayBuffer' | 'json' | 'text' /** * Throw an error instead of returning it in the response? * * @default false */ - throwOnError?: T['throwOnError']; + throwOnError?: T['throwOnError'] } export interface RequestOptions< ThrowOnError extends boolean = boolean, Url extends string = string, > extends Config<{ - throwOnError: ThrowOnError; - }> { + throwOnError: ThrowOnError +}> { /** * Any body that you want to add to your request. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} */ - body?: unknown; - path?: Record; - query?: Record; + body?: unknown + path?: Record + query?: Record /** * Security mechanism(s) to use for the request. */ - security?: ReadonlyArray; - url: Url; + security?: ReadonlyArray + url: Url headers?: Headers | Record } @@ -67,38 +66,34 @@ export type RequestResult< TError = unknown, ThrowOnError extends boolean = boolean, > = ThrowOnError extends true - ? Promise< - { + ? Promise<{ + data: TData extends Record ? TData[keyof TData] : TData + request: Request + response: Response + }> + : Promise< + ( + | { data: TData extends Record ? TData[keyof TData] - : TData; - request: Request; - response: Response; + : TData + error: undefined } - > - : Promise< - ( - | { - data: TData extends Record - ? TData[keyof TData] - : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record - ? TError[keyof TError] - : TError; - } - ) & { - request: Request; - response: Response; + | { + data: undefined + error: TError extends Record + ? TError[keyof TError] + : TError } - >; + ) & { + request: Request + response: Response + } + > export interface ClientOptions { - baseUrl?: string; - throwOnError?: boolean; + baseUrl?: string + throwOnError?: boolean } type MethodFn = < @@ -106,8 +101,8 @@ type MethodFn = < TError = unknown, ThrowOnError extends boolean = false, >( - options: Omit, 'method'>, -) => RequestResult; + options: Omit, 'method'> +) => RequestResult type RequestFn = < TData = unknown, @@ -115,23 +110,23 @@ type RequestFn = < ThrowOnError extends boolean = false, >( options: Omit, 'method'> & - Pick>, 'method'>, -) => RequestResult; + Pick>, 'method'> +) => RequestResult type BuildUrlFn = < TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; + body?: unknown + path?: Record + query?: Record + url: string }, >( - options: Pick & Options, -) => string; + options: Pick & Options +) => string export type Client = CoreClient & { - interceptors: Middleware; -}; + interceptors: Middleware +} /** * The `createClientConfig()` function will be called on client initialization @@ -142,24 +137,24 @@ export type Client = CoreClient & { * to ensure your client always has the correct values. */ export type CreateClientConfig = ( - override?: Config, -) => Config & T>; + override?: Config +) => Config & T> export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; + body?: unknown + headers?: unknown + path?: unknown + query?: unknown + url: string } -type OmitKeys = Pick>; +type OmitKeys = Pick> export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, > = OmitKeys, 'body' | 'path' | 'query' | 'url'> & - Omit; + Omit export type OptionsLegacyParser< TData = unknown, @@ -174,4 +169,4 @@ export type OptionsLegacyParser< ? OmitKeys, 'headers' | 'url'> & TData & Pick, 'body'> - : OmitKeys, 'url'> & TData; + : OmitKeys, 'url'> & TData diff --git a/src/paperback-openapi-client/utils.ts b/src/paperback-openapi-client/utils.ts index 138536e..776040d 100644 --- a/src/paperback-openapi-client/utils.ts +++ b/src/paperback-openapi-client/utils.ts @@ -1,62 +1,62 @@ -import { getAuthToken } from './core/auth.js'; +import { getAuthToken } from './core/auth.js' import type { QuerySerializer, QuerySerializerOptions, -} from './core/bodySerializer.js'; -import { jsonBodySerializer } from './core/bodySerializer.js'; +} from './core/bodySerializer.js' +import { jsonBodySerializer } from './core/bodySerializer.js' import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from './core/pathSerializer.js'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.js'; -import { Headers } from './Headers+Disposable.js'; +} from './core/pathSerializer.js' +import type { Client, ClientOptions, Config, RequestOptions } from './types.js' +import { Headers } from './Headers+Disposable.js' interface PathSerializer { - path: Record; - url: string; + path: Record + url: string } -const PATH_PARAM_RE = /\{[^{}]+\}/g; +const PATH_PARAM_RE = /\{[^{}]+\}/g -type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited' +type MatrixStyle = 'label' | 'matrix' | 'simple' +type ArraySeparatorStyle = ArrayStyle | MatrixStyle const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); + let url = _url + const matches = _url.match(PATH_PARAM_RE) if (matches) { for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; + let explode = false + let name = match.substring(1, match.length - 1) + let style: ArraySeparatorStyle = 'simple' if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); + explode = true + name = name.substring(0, name.length - 1) } if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; + name = name.substring(1) + style = 'label' } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; + name = name.substring(1) + style = 'matrix' } - const value = path[name]; + const value = path[name] if (value === undefined || value === null) { - continue; + continue } if (Array.isArray(value)) { url = url.replace( match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; + serializeArrayParam({ explode, name, style, value }) + ) + continue } if (typeof value === 'object') { @@ -68,9 +68,9 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { style, value: value as Record, valueOnly: true, - }), - ); - continue; + }) + ) + continue } if (style === 'matrix') { @@ -79,19 +79,19 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { `;${serializePrimitiveParam({ name, value: value as string, - })}`, - ); - continue; + })}` + ) + continue } const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); + style === 'label' ? `.${value as string}` : (value as string) + ) + url = url.replace(match, replaceValue) } } - return url; -}; + return url +} export const createQuerySerializer = ({ allowReserved, @@ -99,13 +99,13 @@ export const createQuerySerializer = ({ object, }: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { - let search: string[] = []; + let search: string[] = [] if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { - const value = queryParams[name]; + const value = queryParams[name] if (value === undefined || value === null) { - continue; + continue } if (Array.isArray(value)) { @@ -119,8 +119,8 @@ export const createQuerySerializer = ({ value, ...array, }), - ]; - continue; + ] + continue } if (typeof value === 'object') { @@ -134,8 +134,8 @@ export const createQuerySerializer = ({ value: value as Record, ...object, }), - ]; - continue; + ] + continue } search = [ @@ -145,49 +145,49 @@ export const createQuerySerializer = ({ name, value: value as string, }), - ]; + ] } } - return search.join('&'); - }; - return querySerializer; -}; + return search.join('&') + } + return querySerializer +} export const setAuthParams = async ({ security, ...options }: Pick, 'security'> & Pick & { - headers: Headers; + headers: Headers }) => { for (const auth of security) { - const token = await getAuthToken(auth, options.auth); + const token = await getAuthToken(auth, options.auth) if (!token) { - continue; + continue } - const name = auth.name ?? 'Authorization'; + const name = auth.name ?? 'Authorization' switch (auth.in) { case 'query': if (!options.query) { - options.query = {}; + options.query = {} } - options.query[name] = token; - break; + options.query[name] = token + break case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; + options.headers.append('Cookie', `${name}=${token}`) + break case 'header': default: - options.headers.set(name, token); - break; + options.headers.set(name, token) + break } - return; + return } -}; +} export const buildUrl: Client['buildUrl'] = (options) => { const url = getUrl({ @@ -199,9 +199,9 @@ export const buildUrl: Client['buildUrl'] = (options) => { ? options.querySerializer : createQuerySerializer(options.querySerializer), url: options.url, - }); - return url; -}; + }) + return url +} export const getUrl = ({ baseUrl, @@ -210,110 +210,110 @@ export const getUrl = ({ querySerializer, url: _url, }: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; + baseUrl?: string + path?: Record + query?: Record + querySerializer: QuerySerializer + url: string }) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; + const pathUrl = _url.startsWith('/') ? _url : `/${_url}` + let url = (baseUrl ?? '') + pathUrl if (path) { - url = defaultPathSerializer({ path, url }); + url = defaultPathSerializer({ path, url }) } - let search = query ? querySerializer(query) : ''; + let search = query ? querySerializer(query) : '' if (search.startsWith('?')) { - search = search.substring(1); + search = search.substring(1) } if (search) { - url += `?${search}`; + url += `?${search}` } - return url; -}; + return url +} export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; + const config = { ...a, ...b } if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; + config.headers = mergeHeaders(a.headers, b.headers) + return config +} export const mergeHeaders = ( ...headers: Array['headers'] | undefined> ): Headers => { - const mergedHeaders = new Headers(); + const mergedHeaders = new Headers() for (const header of headers) { if (!header || typeof header !== 'object') { - continue; + continue } const iterator = - header instanceof Headers ? header.entries() : Object.entries(header); + header instanceof Headers ? header.entries() : Object.entries(header) for (const [key, value] of iterator) { if (value === null) { - mergedHeaders.delete(key); + mergedHeaders.delete(key) } else if (Array.isArray(value)) { for (const v of value) { - mergedHeaders.append(key, v as string); + mergedHeaders.append(key, v as string) } } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' mergedHeaders.set( key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); + typeof value === 'object' ? JSON.stringify(value) : (value as string) + ) } } } - return mergedHeaders; -}; + return mergedHeaders +} type ErrInterceptor = ( error: Err, response: Res, request: Req, - options: Options, -) => Err | Promise; + options: Options +) => Err | Promise type ReqInterceptor = ( request: Req, - options: Options, -) => Req | Promise; + options: Options +) => Req | Promise type ResInterceptor = ( response: Res, request: Req, - options: Options, -) => Res | Promise; + options: Options +) => Res | Promise class Interceptors { - _fns: Interceptor[]; + _fns: Interceptor[] constructor() { - this._fns = []; + this._fns = [] } clear() { - this._fns = []; + this._fns = [] } exists(fn: Interceptor) { - return this._fns.indexOf(fn) !== -1; + return this._fns.indexOf(fn) !== -1 } eject(fn: Interceptor) { - const index = this._fns.indexOf(fn); + const index = this._fns.indexOf(fn) if (index !== -1) { - this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; + this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)] } } use(fn: Interceptor) { - this._fns = [...this._fns, fn]; + this._fns = [...this._fns, fn] } } @@ -323,12 +323,12 @@ export interface Middleware { error: Pick< Interceptors>, 'eject' | 'use' - >; - request: Pick>, 'eject' | 'use'>; + > + request: Pick>, 'eject' | 'use'> response: Pick< Interceptors>, 'eject' | 'use' - >; + > } // do not add `Middleware` as return type so we can use _fns internally @@ -336,7 +336,7 @@ export const createInterceptors = () => ({ error: new Interceptors>(), request: new Interceptors>(), response: new Interceptors>(), -}); +}) const defaultQuerySerializer = createQuerySerializer({ allowReserved: false, @@ -348,18 +348,18 @@ const defaultQuerySerializer = createQuerySerializer({ explode: true, style: 'deepObject', }, -}); +}) const defaultHeaders = { 'Content-Type': 'application/json', -}; +} export const createConfig = ( - override: Config & T> = {}, + override: Config & T> = {} ): Config & T> => ({ ...jsonBodySerializer, headers: defaultHeaders, parseAs: 'json', querySerializer: defaultQuerySerializer, ...override, -}); +}) diff --git a/src/tests/Komga.ts b/src/tests/Komga.ts index e4a0b92..ff27c73 100644 --- a/src/tests/Komga.ts +++ b/src/tests/Komga.ts @@ -1,12 +1,11 @@ +import { type TestLogger } from '@paperback/types' +import { TestSuite, registerDefaultTests } from './suite.js' +import { Komga } from '../Komga/main.js' +import sourceInfo from '../Komga/pbconfig.js' - import { type TestLogger } from '@paperback/types' - import { TestSuite, registerDefaultTests } from './suite.js' - import { Komga } from '../Komga/main.js' - import sourceInfo from '../Komga/pbconfig.js' - - export async function runTests(logger: TestLogger) { - const suite = new TestSuite('Komga tests', logger) - registerDefaultTests(suite, Komga, sourceInfo) - - await suite.run() - } \ No newline at end of file +export async function runTests(logger: TestLogger) { + const suite = new TestSuite('Komga tests', logger) + registerDefaultTests(suite, Komga, sourceInfo) + + await suite.run() +} diff --git a/src/tests/Komga_2.ts b/src/tests/Komga_2.ts index 109587b..459b7e0 100644 --- a/src/tests/Komga_2.ts +++ b/src/tests/Komga_2.ts @@ -1,12 +1,11 @@ +import { type TestLogger } from '@paperback/types' +import { TestSuite, registerDefaultTests } from './suite.js' +import { Komga_2 } from '../Komga_2/main.js' +import sourceInfo from '../Komga_2/pbconfig.js' - import { type TestLogger } from '@paperback/types' - import { TestSuite, registerDefaultTests } from './suite.js' - import { Komga_2 } from '../Komga_2/main.js' - import sourceInfo from '../Komga_2/pbconfig.js' - - export async function runTests(logger: TestLogger) { - const suite = new TestSuite('Komga_2 tests', logger) - registerDefaultTests(suite, Komga_2, sourceInfo) - - await suite.run() - } \ No newline at end of file +export async function runTests(logger: TestLogger) { + const suite = new TestSuite('Komga_2 tests', logger) + registerDefaultTests(suite, Komga_2, sourceInfo) + + await suite.run() +} From e2cef6d9dc3452c335b530dd4232756e4ab167e5 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:11:34 -0400 Subject: [PATCH 08/31] ci: typecheck, lint and format check before bundling The workflow published to gh-pages without anything verifying the sources compiled. --- .github/workflows/main.yml | 1 + package.json | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 56f2019..1d85ea9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,6 +27,7 @@ jobs: path: bundles - run: npm install + - run: npm run check - run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }} - name: Deploy to GitHub Pages diff --git a/package.json b/package.json index b56cb18..866aa8b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "node": ">=18.0.0" }, "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint src", + "format": "prettier --write src", + "format:check": "prettier --check src", + "check": "npm run typecheck && npm run lint && npm run format:check", "test": "npx paperback-cli test", "bundle": "npx paperback-cli bundle", "serve": "npx paperback-cli serve", From b95f86df221f4033f2e3f94c74233a8f823deb86 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:16:51 -0400 Subject: [PATCH 09/31] chore(Komga): add Exikle to developers and bump to 3.1 --- src/Komga/pbconfig.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 8226e19..d68dd90 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.0', + version: '3.1', name: 'Komga', icon: 'icon.png', developers: [ @@ -17,6 +17,10 @@ export default { name: 'Lemon', github: 'FramboisePi', }, + { + name: 'Exikle', + github: 'Exikle', + }, ], description: 'Komga client extension for Paperback', contentRating: ContentRating.EVERYONE, From 34d5283c9f5305c7537fbe1789163684fcd1c708 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:42:05 -0400 Subject: [PATCH 10/31] feat(Komga): rate content on search and discover items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contentRating was only set on the manga details page, so search results and every carousel item shipped it unset and the app had nothing to filter on. Komga libraries rarely populate ageRating — every series in the test library has it null — so the rating now falls back to genres when it is absent. Books carry no genres, which leaves On Deck unrated. --- src/Komga/komga.ts | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 7e62718..9b54fb1 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -81,15 +81,34 @@ export const capitalize = (tag: string): string => { return tag.replace(/^\w/, (c) => c.toUpperCase()) } -// Komga stores an age rating as a minimum age -export const parseContentRating = (ageRating?: number): ContentRating => { - if (ageRating === undefined) { +// Komga libraries rarely set ageRating, so genres are the usable signal. These +// are matched case-insensitively against SeriesMetadataDto.genres. +const ADULT_GENRES = ['adult', 'hentai', 'smut', 'erotica', 'pornographic'] +const MATURE_GENRES = ['mature', 'ecchi'] + +// ageRating is a minimum age when set; otherwise fall back to genres +export const parseContentRating = (metadata: { + ageRating?: number + genres?: Array +}): ContentRating => { + // Komga sends `ageRating: null` on the wire even though the generated type + // declares it optional, so check the runtime type rather than for undefined + const { ageRating } = metadata + if (typeof ageRating === 'number') { + if (ageRating >= 18) { + return ContentRating.ADULT + } + if (ageRating >= 16) { + return ContentRating.MATURE + } return ContentRating.EVERYONE } - if (ageRating >= 18) { + + const genres = (metadata.genres ?? []).map((genre) => genre.toLowerCase()) + if (genres.some((genre) => ADULT_GENRES.includes(genre))) { return ContentRating.ADULT } - if (ageRating >= 16) { + if (genres.some((genre) => MATURE_GENRES.includes(genre))) { return ContentRating.MATURE } return ContentRating.EVERYONE @@ -244,7 +263,7 @@ export class KomgaExtension implements ExtensionImpl { thumbnailUrl: thumbnailUrl, primaryTitle: metadata.title, secondaryTitles: metadata.alternateTitles.map((alt) => alt.title), - contentRating: parseContentRating(metadata.ageRating), + contentRating: parseContentRating(metadata), status: parseMangaStatus(metadata.status), artist: artists.join(', '), author: authors.join(', '), @@ -254,6 +273,10 @@ export class KomgaExtension implements ExtensionImpl { language: metadata.language, readingDirection: metadata.readingDirection, publisher: metadata.publisher, + books: String(result.booksCount), + booksRead: String(result.booksReadCount), + booksUnread: String(result.booksUnreadCount), + booksInProgress: String(result.booksInProgressCount), }, }, } @@ -432,6 +455,7 @@ export class KomgaExtension implements ExtensionImpl { title: serie.metadata.title, mangaId: serie.id, subtitle: undefined, + contentRating: parseContentRating(serie.metadata), }) } @@ -626,6 +650,7 @@ export class KomgaExtension implements ExtensionImpl { imageUrl: thumbnailUrl, mangaId: serie.id, subtitle: undefined, + contentRating: parseContentRating(serie.metadata), }) } @@ -653,6 +678,7 @@ export class KomgaExtension implements ExtensionImpl { imageUrl: thumbnailUrl, mangaId: serie.id, subtitle: undefined, + contentRating: parseContentRating(serie.metadata), }) } @@ -680,6 +706,7 @@ export class KomgaExtension implements ExtensionImpl { imageUrl: thumbnailUrl, mangaId: serie.id, subtitle: undefined, + contentRating: parseContentRating(serie.metadata), }) } From 8006e593de5a2a2b7399ecf3a7905bfa5ffdca98 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:09:43 -0400 Subject: [PATCH 11/31] fix(Komga): settings form bugs - both navigation rows shared the id 'authentication' - the password field rendered in plaintext, InputRow has isSecureEntry - only a 400 carries `violations`, so a 404 or an unreachable host threw a TypeError instead of reporting the real problem - "Hompage" typo in the section header --- src/Komga/forms/homepage_settings_form.ts | 2 +- src/Komga/forms/server_settings_form.ts | 22 ++++++++++++++++++---- src/Komga/forms/settings_form.ts | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index b1cbe0b..163b3ed 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -18,7 +18,7 @@ export class HomepageSettingsForm extends Form { staticHomepageSection(): FormSectionElement { return Section( - { id: 'staticHomepageSection', header: 'Static Hompage Sections' }, + { id: 'staticHomepageSection', header: 'Static Homepage Sections' }, [ ToggleRow('showOnDeck', { title: 'On Deck', diff --git a/src/Komga/forms/server_settings_form.ts b/src/Komga/forms/server_settings_form.ts index 6d4346c..cebc426 100644 --- a/src/Komga/forms/server_settings_form.ts +++ b/src/Komga/forms/server_settings_form.ts @@ -62,6 +62,7 @@ export class ServerSettingsForm extends Form { InputRow('password', { title: 'Password', value: this.credentials.password, + isSecureEntry: true, onValueChange: Application.Selector( this as ServerSettingsForm, 'passwordDidChange' @@ -96,13 +97,26 @@ export class ServerSettingsForm extends Form { return } - switch (response.status) { - case 401: { - throw new Error('Error 401 Unauthorized: Invalid credentials') + // Only a 400 is typed as carrying `violations`; every other failure (401, + // 5xx, an unreachable host) has no such field, so reaching for it here + // threw a TypeError instead of showing the real problem. + switch (response?.status) { + case undefined: { + throw new Error(`Could not reach ${this.baseUrl}. Check the URL.`) + } + case 401: + case 403: { + throw new Error(`Error ${response.status}: invalid credentials`) + } + case 404: { + throw new Error( + `Error 404: no Komga server at ${this.baseUrl}. Check the URL.` + ) } default: { + const violations = error?.violations?.map((x) => x.message).join('\n') throw new Error( - `Error ${response.status}: ${error.violations.map((x) => x.message).join('\n')}` + `Error ${response.status}${violations ? `: ${violations}` : ''}` ) } } diff --git a/src/Komga/forms/settings_form.ts b/src/Komga/forms/settings_form.ts index 4afa2ea..120d4be 100644 --- a/src/Komga/forms/settings_form.ts +++ b/src/Komga/forms/settings_form.ts @@ -18,7 +18,7 @@ export class SettingsForm extends Form { ]), Section('homepageSettings', [ - NavigationRow('authentication', { + NavigationRow('homepageSettings', { title: 'Homepage Settings', form: new HomepageSettingsForm(), }), From b35031ad6177323703e1f61c8d3b41cf83173c1b Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:17:30 -0400 Subject: [PATCH 12/31] feat(Komga): content filter and toggles for the remaining sections Adds a Content Settings form with a Hide Adult Content switch and an editable genre list, loaded from the server's own genres. Search and Continue Reading exclude them server-side through the search condition; Recently Added and Recently Updated have no condition parameter so they are filtered client-side. On Deck cannot be filtered as Komga exposes no genres on books. Recently Added and Recently Updated also get show/hide toggles, which the other two sections already had. --- src/Komga/forms/content_settings_form.ts | 91 +++++++++++++++++++++++ src/Komga/forms/homepage_settings_form.ts | 30 ++++++++ src/Komga/forms/settings_form.ts | 8 ++ src/Komga/komga.ts | 71 ++++++++++++++---- src/Komga/utils/config.ts | 57 ++++++++++++++ 5 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 src/Komga/forms/content_settings_form.ts diff --git a/src/Komga/forms/content_settings_form.ts b/src/Komga/forms/content_settings_form.ts new file mode 100644 index 0000000..13b1367 --- /dev/null +++ b/src/Komga/forms/content_settings_form.ts @@ -0,0 +1,91 @@ +import { + Form, + Section, + SelectRow, + ToggleRow, + type FormSectionElement, +} from '@paperback/types' +import { + getAdultGenres, + getHideAdultContent, + setAdultGenres, + setHideAdultContent, +} from '../utils/config.js' +import { getGenres } from '../sdk/index.js' + +export class ContentSettingsForm extends Form { + private hideAdultContent = getHideAdultContent() + private adultGenres = getAdultGenres() + // Populated from the server; getSections is sync so the fetch happens in + // formWillAppear and reloads the form when it lands + private availableGenres: string[] = [] + + override formWillAppear(): void { + void this.loadGenres() + } + + private async loadGenres(): Promise { + const genres = await getGenres() + .then((r) => r.data ?? []) + .catch(() => []) + + // Keep any configured genre the server no longer reports, otherwise + // selecting it would silently vanish from the list + this.availableGenres = [ + ...new Set([...genres.map((g) => g.toLowerCase()), ...this.adultGenres]), + ].sort() + + this.reloadForm() + } + + override getSections(): FormSectionElement[] { + return [ + Section( + { + id: 'adultContent', + footer: + 'Hides series whose genres match the list below. Komga rarely sets an age rating, so this matches on genres instead. On Deck cannot be filtered because Komga does not expose genres on books.', + }, + [ + ToggleRow('hideAdultContent', { + title: 'Hide Adult Content', + value: this.hideAdultContent, + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'hideAdultContentDidChange' + ), + }), + SelectRow('adultGenres', { + title: 'Genres To Hide', + value: this.adultGenres, + minItemCount: 0, + maxItemCount: this.availableGenres.length, + layout: 'list', + items: this.availableGenres.map((genre) => ({ + id: genre, + title: genre, + })), + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'adultGenresDidChange' + ), + }), + ] + ), + ] + } + + async hideAdultContentDidChange(newValue: boolean): Promise { + this.hideAdultContent = newValue + setHideAdultContent(newValue) + Application.invalidateDiscoverSections() + this.reloadForm() + } + + async adultGenresDidChange(newValue: string[]): Promise { + this.adultGenres = newValue + setAdultGenres(newValue) + Application.invalidateDiscoverSections() + this.reloadForm() + } +} diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index 163b3ed..eb3017d 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -7,8 +7,12 @@ import { import { getShowContinueReading, getShowOnDeck, + getShowRecentlyAdded, + getShowRecentlyUpdated, setShowContinueReading, setShowOnDeck, + setShowRecentlyAdded, + setShowRecentlyUpdated, } from '../utils/config.js' export class HomepageSettingsForm extends Form { @@ -36,6 +40,22 @@ export class HomepageSettingsForm extends Form { 'showContinueReadingDidChange' ), }), + ToggleRow('showRecentlyAdded', { + title: 'Recently Added', + value: getShowRecentlyAdded(), + onValueChange: Application.Selector( + this as HomepageSettingsForm, + 'showRecentlyAddedDidChange' + ), + }), + ToggleRow('showRecentlyUpdated', { + title: 'Recently Updated', + value: getShowRecentlyUpdated(), + onValueChange: Application.Selector( + this as HomepageSettingsForm, + 'showRecentlyUpdatedDidChange' + ), + }), ] ) } @@ -49,4 +69,14 @@ export class HomepageSettingsForm extends Form { setShowContinueReading(newValue) Application.invalidateDiscoverSections() } + + async showRecentlyAddedDidChange(newValue: boolean): Promise { + setShowRecentlyAdded(newValue) + Application.invalidateDiscoverSections() + } + + async showRecentlyUpdatedDidChange(newValue: boolean): Promise { + setShowRecentlyUpdated(newValue) + Application.invalidateDiscoverSections() + } } diff --git a/src/Komga/forms/settings_form.ts b/src/Komga/forms/settings_form.ts index 120d4be..f9afaca 100644 --- a/src/Komga/forms/settings_form.ts +++ b/src/Komga/forms/settings_form.ts @@ -6,6 +6,7 @@ import { } from '@paperback/types' import { ServerSettingsForm } from './server_settings_form.js' import { HomepageSettingsForm } from './homepage_settings_form.js' +import { ContentSettingsForm } from './content_settings_form.js' export class SettingsForm extends Form { override getSections(): FormSectionElement[] { @@ -23,6 +24,13 @@ export class SettingsForm extends Form { form: new HomepageSettingsForm(), }), ]), + + Section('contentSettings', [ + NavigationRow('contentSettings', { + title: 'Content Settings', + form: new ContentSettingsForm(), + }), + ]), ] } } diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 9b54fb1..0184210 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -53,12 +53,16 @@ import { } from './sdk/index.js' import { client } from './sdk/client.gen.js' import { KomgaImageInterceptor } from './interceptors/image_interceptor.js' -import { isEqualTo, isFalse, Operator } from './utils.js' +import { isEqualTo, isFalse, isNotEqualTo, Operator } from './utils.js' import { + getAdultGenres, + getHideAdultContent, getKomgaBaseURL, getKomgaCredentials, getShowContinueReading, getShowOnDeck, + getShowRecentlyAdded, + getShowRecentlyUpdated, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' import { parseChapterTitle } from './utils/titles.js' @@ -114,6 +118,26 @@ export const parseContentRating = (metadata: { return ContentRating.EVERYONE } +// `/series/list` takes a search condition, so hidden genres are excluded by the +// server. `/series/new` and `/series/updated` take no condition, so those get +// filtered here instead. +const hiddenGenreConditions = () => { + if (!getHideAdultContent()) { + return [] + } + return getAdultGenres().map((genre) => ({ genre: isNotEqualTo(genre) })) +} + +const isHiddenSeries = (metadata: { genres?: Array }): boolean => { + if (!getHideAdultContent()) { + return false + } + const hidden = getAdultGenres() + return (metadata.genres ?? []).some((genre) => + hidden.includes(genre.toLowerCase()) + ) +} + export class KomgaExtension implements ExtensionImpl { async getAdvancedSearchForm( query: SearchQuery @@ -386,7 +410,7 @@ export class KomgaExtension implements ExtensionImpl { const page: number = metadata?.page ?? 0 // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filters: any[] = [] + const filters: any[] = [...hiddenGenreConditions()] for (const filter of searchQuery.metadata ?? []) { const value = filter.value @@ -578,17 +602,21 @@ export class KomgaExtension implements ExtensionImpl { }) } - sections.push({ - id: 'recentlyAdded', - title: 'Recently Added', - type: DiscoverSectionType.simpleCarousel, - }) + if (getShowRecentlyAdded()) { + sections.push({ + id: 'recentlyAdded', + title: 'Recently Added', + type: DiscoverSectionType.simpleCarousel, + }) + } - sections.push({ - id: 'recentlyUpdated', - title: 'Recently Updated', - type: DiscoverSectionType.simpleCarousel, - }) + if (getShowRecentlyUpdated()) { + sections.push({ + id: 'recentlyUpdated', + title: 'Recently Updated', + type: DiscoverSectionType.simpleCarousel, + }) + } return sections } @@ -630,8 +658,11 @@ export class KomgaExtension implements ExtensionImpl { query: { sort: ['readProgress.readDate,desc'], page: metadata?.page }, body: { condition: { - deleted: isFalse(), - readStatus: isEqualTo('IN_PROGRESS'), + allOf: [ + { deleted: isFalse() }, + { readStatus: isEqualTo('IN_PROGRESS') }, + ...hiddenGenreConditions(), + ], }, }, }) @@ -642,6 +673,10 @@ export class KomgaExtension implements ExtensionImpl { const items: DiscoverSectionItem[] = [] for (const serie of data.content ?? []) { + if (isHiddenSeries(serie.metadata)) { + continue + } + const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` items.push({ @@ -670,6 +705,10 @@ export class KomgaExtension implements ExtensionImpl { const items: DiscoverSectionItem[] = [] for (const serie of data.content ?? []) { + if (isHiddenSeries(serie.metadata)) { + continue + } + const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` items.push({ @@ -698,6 +737,10 @@ export class KomgaExtension implements ExtensionImpl { const items: DiscoverSectionItem[] = [] for (const serie of data.content ?? []) { + if (isHiddenSeries(serie.metadata)) { + continue + } + const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` items.push({ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 6a523a6..13f5daf 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -3,12 +3,29 @@ const KEY_KOMGA_USERNAME = 'serverUsername' const KEY_KOMGA_PASSWORD = 'serverPassword' const KEY_SHOW_ON_DECK = 'showOnDeck' const KEY_SHOW_CONTINUE_READING = 'showContinueReading' +const KEY_SHOW_RECENTLY_ADDED = 'showRecentlyAdded' +const KEY_SHOW_RECENTLY_UPDATED = 'showRecentlyUpdated' +const KEY_HIDE_ADULT_CONTENT = 'hideAdultContent' +const KEY_ADULT_GENRES = 'adultGenres' const DEFAULT_KOMGA_BASE_URL = 'https://demo.komga.org' const DEFAULT_KOMGA_USERNAME = 'demo@komga.org' const DEFAULT_KOMGA_PASSWORD = 'komga-demo' const DEFAULT_SHOW_ON_DECK = true const DEFAULT_SHOW_CONTINUE_READING = true +const DEFAULT_SHOW_RECENTLY_ADDED = true +const DEFAULT_SHOW_RECENTLY_UPDATED = true +const DEFAULT_HIDE_ADULT_CONTENT = false +// Matched case-insensitively against a series' genres +const DEFAULT_ADULT_GENRES = [ + 'adult', + 'hentai', + 'smut', + 'erotica', + 'pornographic', + 'mature', + 'ecchi', +] function getStateOrDefault(key: string, def: T): T { return (Application.getState(key) as T) ?? def @@ -56,3 +73,43 @@ export function getShowContinueReading() { export function setShowContinueReading(newValue: boolean) { Application.setState(newValue, KEY_SHOW_CONTINUE_READING) } + +export function getShowRecentlyAdded() { + return getStateOrDefault(KEY_SHOW_RECENTLY_ADDED, DEFAULT_SHOW_RECENTLY_ADDED) +} + +export function setShowRecentlyAdded(newValue: boolean) { + Application.setState(newValue, KEY_SHOW_RECENTLY_ADDED) +} + +export function getShowRecentlyUpdated() { + return getStateOrDefault( + KEY_SHOW_RECENTLY_UPDATED, + DEFAULT_SHOW_RECENTLY_UPDATED + ) +} + +export function setShowRecentlyUpdated(newValue: boolean) { + Application.setState(newValue, KEY_SHOW_RECENTLY_UPDATED) +} + +export function getHideAdultContent() { + return getStateOrDefault(KEY_HIDE_ADULT_CONTENT, DEFAULT_HIDE_ADULT_CONTENT) +} + +export function setHideAdultContent(newValue: boolean) { + Application.setState(newValue, KEY_HIDE_ADULT_CONTENT) +} + +// Lower-cased so callers can compare directly against a series' genres +export function getAdultGenres(): string[] { + const stored = Application.getState(KEY_ADULT_GENRES) + const genres = Array.isArray(stored) + ? (stored as string[]) + : DEFAULT_ADULT_GENRES + return genres.map((genre) => genre.toLowerCase()) +} + +export function setAdultGenres(newValue: string[]) { + Application.setState(newValue, KEY_ADULT_GENRES) +} From 7e1219d4163a818433731a2c8271ed2f84d5d886 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:17:30 -0400 Subject: [PATCH 13/31] feat(Komga): test connection button in server settings Reports the result inline with a styled label instead of requiring a blind submit, and reuses the same status messages as submission. --- src/Komga/forms/server_settings_form.ts | 80 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/Komga/forms/server_settings_form.ts b/src/Komga/forms/server_settings_form.ts index cebc426..fe63405 100644 --- a/src/Komga/forms/server_settings_form.ts +++ b/src/Komga/forms/server_settings_form.ts @@ -1,8 +1,11 @@ import { + ButtonRow, Form, InputRow, + LabelRow, Section, type FormSectionElement, + type LabelRowValue, } from '@paperback/types' import { getKomgaBaseURL, @@ -72,8 +75,83 @@ export class ServerSettingsForm extends Form { ) } + // Result of the last `Test Connection` press, shown without saving anything + private connectionStatus: LabelRowValue | undefined + + connectionSection() { + // Section accepts undefined entries, so the status row can simply be absent + // until a check has run + return Section({ id: 'connection' }, [ + ButtonRow('testConnection', { + title: 'Test Connection', + onSelect: Application.Selector( + this as ServerSettingsForm, + 'testConnection' + ), + }), + this.connectionStatus + ? LabelRow('connectionStatus', { + title: 'Status', + value: this.connectionStatus, + }) + : undefined, + ]) + } + + async testConnection(): Promise { + this.connectionStatus = { text: 'Checking...', style: 'tinted' } + this.reloadForm() + + try { + const { data, error, response } = await getCurrentUser({ + baseUrl: this.baseUrl, + auth: (auth) => + auth.scheme === 'basic' + ? `${this.credentials.username}:${this.credentials.password}` + : undefined, + }) + + if (!error && data) { + this.connectionStatus = { + text: `Connected as ${data.email}`, + style: 'success', + } + } else { + this.connectionStatus = { + text: this.describeFailure(response?.status), + style: 'error', + } + } + } catch { + this.connectionStatus = { + text: `Could not reach ${this.baseUrl}`, + style: 'error', + } + } + + this.reloadForm() + } + + private describeFailure(status: number | undefined): string { + switch (status) { + case undefined: + return `Could not reach ${this.baseUrl}` + case 401: + case 403: + return 'Invalid credentials' + case 404: + return 'No Komga server at that URL' + default: + return `Server returned ${status}` + } + } + override getSections(): FormSectionElement[] { - return [this.baseUrlSection(), this.credentialsSection()] + return [ + this.baseUrlSection(), + this.credentialsSection(), + this.connectionSection(), + ] } // Validate the credentials From a624b88bb7547fb923489ec9970d57cc410831c0 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:24:58 -0400 Subject: [PATCH 14/31] fix(Komga): encode genre ids in the content settings form Row ids may only contain alphanumerics or `._-@()[]%?#+=/&:`, so genres with a space such as `martial arts` were rejected at render. Encoded with base64 the same way the search filters already build their genre and tag ids; the stored setting stays as plain genre names. --- src/Komga/forms/content_settings_form.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Komga/forms/content_settings_form.ts b/src/Komga/forms/content_settings_form.ts index 13b1367..058b30c 100644 --- a/src/Komga/forms/content_settings_form.ts +++ b/src/Komga/forms/content_settings_form.ts @@ -57,12 +57,15 @@ export class ContentSettingsForm extends Form { }), SelectRow('adultGenres', { title: 'Genres To Hide', - value: this.adultGenres, + // Row ids may only use `._-@()[]%?#+=/&:`, so a genre like + // `martial arts` has to be encoded. base64 is what the search + // filters already use and its alphabet is within that set. + value: this.adultGenres.map(btoa), minItemCount: 0, maxItemCount: this.availableGenres.length, layout: 'list', items: this.availableGenres.map((genre) => ({ - id: genre, + id: btoa(genre), title: genre, })), onValueChange: Application.Selector( @@ -83,8 +86,9 @@ export class ContentSettingsForm extends Form { } async adultGenresDidChange(newValue: string[]): Promise { - this.adultGenres = newValue - setAdultGenres(newValue) + const genres = newValue.map(atob) + this.adultGenres = genres + setAdultGenres(genres) Application.invalidateDiscoverSections() this.reloadForm() } From e0f00d3a756f0505022a1eaceb9f69cf70279059 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:36:40 -0400 Subject: [PATCH 15/31] feat(Komga): expose the series share url and total book count Komga records external links per series (AniList, MangaDex, MangaUpdates and others) but MangaInfo.shareUrl was left unset, so the app's share action had no target. Picks the first match from a preference order, falling back to whatever link exists. Also surfaces totalBookCount, which is the intended length of the series as opposed to the books actually present. --- src/Komga/komga.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 0184210..006f2ac 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -118,6 +118,29 @@ export const parseContentRating = (metadata: { return ContentRating.EVERYONE } +// Preferred targets for the app's share action, best first. Komga records many +// links per series; anything not listed here is used only as a last resort. +const SHARE_LINK_PREFERENCE = [ + 'anilist', + 'mangadex', + 'mangaupdates', + 'myanimelist', +] + +const pickShareUrl = ( + links: Array<{ label: string; url: string }> +): string | undefined => { + for (const preferred of SHARE_LINK_PREFERENCE) { + const match = links.find( + (link) => link.label.toLowerCase().replace(/\s+/g, '') === preferred + ) + if (match) { + return match.url + } + } + return links[0]?.url +} + // `/series/list` takes a search condition, so hidden genres are excluded by the // server. `/series/new` and `/series/updated` take no condition, so those get // filtered here instead. @@ -293,11 +316,15 @@ export class KomgaExtension implements ExtensionImpl { author: authors.join(', '), synopsis: metadata.summary ? metadata.summary : booksMetadata.summary, tagGroups: tagSections, + shareUrl: pickShareUrl(metadata.links), additionalInfo: { language: metadata.language, readingDirection: metadata.readingDirection, publisher: metadata.publisher, books: String(result.booksCount), + ...(metadata.totalBookCount === undefined + ? {} + : { totalBooks: String(metadata.totalBookCount) }), booksRead: String(result.booksReadCount), booksUnread: String(result.booksUnreadCount), booksInProgress: String(result.booksInProgressCount), From 1bdb9a7fc3c4291a8d99b1b7e4173eb40f017e85 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:41:33 -0400 Subject: [PATCH 16/31] feat(Komga): expose sorting options for search Search was pinned to a hardcoded sort behind a dead `orderResultsAlphabetically` local. That sort was also broken: Komga ignores an unrecognised sort field instead of erroring, and `titleSort` is one of them, so results were never actually ordered by title. The working field is `metadata.titleSort`. Every option was checked against a live server by confirming asc and desc return different results; `releaseDate` and `folderName` are ignored the same way and are deliberately left out. --- src/Komga/komga.ts | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 006f2ac..961f945 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -32,6 +32,7 @@ import { type PagedResults, type SearchQuery, type SearchResultItem, + type SortingOption, type SourceManga, type TagSection, type TrackedMangaChapterReadAction, @@ -118,6 +119,25 @@ export const parseContentRating = (metadata: { return ContentRating.EVERYONE } +// Komga silently ignores an unrecognised sort field rather than erroring, so +// every entry here was checked against a live server by confirming asc and desc +// actually differ. `titleSort`, `releaseDate` and `folderName` are all ignored; +// the working title field is `metadata.titleSort`. +const DEFAULT_SORT = 'metadata.titleSort,asc' + +const SORT_OPTIONS: Array = [ + { id: 'titleAsc', label: 'Title (A-Z)', sort: DEFAULT_SORT }, + { id: 'titleDesc', label: 'Title (Z-A)', sort: 'metadata.titleSort,desc' }, + { id: 'recentlyAdded', label: 'Recently Added', sort: 'created,desc' }, + { + id: 'recentlyUpdated', + label: 'Recently Updated', + sort: 'lastModified,desc', + }, + { id: 'recentlyRead', label: 'Recently Read', sort: 'readDate,desc' }, + { id: 'mostChapters', label: 'Most Chapters', sort: 'booksCount,desc' }, +] + // Preferred targets for the app's share action, best first. Komga records many // links per series; anything not listed here is used only as a last resort. const SHARE_LINK_PREFERENCE = [ @@ -424,15 +444,19 @@ export class KomgaExtension implements ExtensionImpl { ] } + async getSortingOptions(): Promise { + return SORT_OPTIONS.map(({ id, label }) => ({ id, label })) + } + async getSearchResults( searchQuery: SearchQuery, - metadata: { page: number } | undefined + metadata: { page: number } | undefined, + sortingOption: SortingOption | undefined ): Promise> { // This function is also called when the user search in an other source. It should not throw if the server is unavailable. - // We won't use `await this.getKomgaAPI()` as we do not want to throw an error - // const komgaAPI = await getKomgaAPI(stateManager); - // const { orderResultsAlphabetically } = await getOptions(stateManager); - const orderResultsAlphabetically = true + const sort = + SORT_OPTIONS.find((option) => option.id === sortingOption?.id)?.sort ?? + DEFAULT_SORT const page: number = metadata?.page ?? 0 @@ -478,7 +502,7 @@ export class KomgaExtension implements ExtensionImpl { query: { page, size: PAGE_SIZE, - sort: [orderResultsAlphabetically ? 'titleSort' : 'lastModified,desc'], + sort: [sort], }, body: { fullTextSearch: searchQuery.title, From 5275f2bad52ef07799430646a9bb3b2b14469881 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:53:19 -0400 Subject: [PATCH 17/31] chore(Komga): move to semver, 3.2.0 Versions were major.minor; other Paperback extensions use full semver, so match that. Major stays at 3: nothing since the 3.0 tracker release breaks compatibility, the new settings keys are additive and no capability was dropped. Minor goes to 2 rather than 1 because several different builds already shipped as 3.1, and 3.1.0 could compare equal to those. --- src/Komga/pbconfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index d68dd90..45054dd 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.1', + version: '3.2.0', name: 'Komga', icon: 'icon.png', developers: [ From 74d0080a3a35906c67ac01fec7cae86baeed2440 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:58:46 -0400 Subject: [PATCH 18/31] feat(Komga): bulk update check, 3.3.0 The app was calling getChapters once per tracked series on every update run, 86 requests for this library. Komga has no "changed since" search condition, but /series/updated is ordered by lastModified descending, so paging until the cutoff identifies everything that moved in one or two requests and the rest is marked skippable. A series' lastModified can trail its books' created time by a few seconds when both are written during a single library scan, so the cutoff looks an hour further back than the app's last check. Checked against every series in a 14.6k book library: a daily run drops from 86 requests to 27 with nothing missed. A failed request returns without setting any priority, since marking titles skipped on incomplete information would swallow real updates. --- src/Komga/komga.ts | 63 +++++++++++++++++++++++++++++++++++++++++++ src/Komga/pbconfig.ts | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 961f945..d54edc3 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -34,6 +34,7 @@ import { type SearchResultItem, type SortingOption, type SourceManga, + type UpdateManager, type TagSection, type TrackedMangaChapterReadAction, } from '@paperback/types' @@ -123,6 +124,14 @@ export const parseContentRating = (metadata: { // every entry here was checked against a live server by confirming asc and desc // actually differ. `titleSort`, `releaseDate` and `folderName` are all ignored; // the working title field is `metadata.titleSort`. +// A series' lastModified can trail its books' created time by a few seconds +// when both are written during one library scan, so look slightly further back +// than the app's last check to avoid skipping a series that did gain chapters. +const UPDATE_CHECK_MARGIN_MS = 60 * 60 * 1000 + +// Guards the paging loop; far more than a sane library needs in one pass +const MAX_UPDATE_PAGES = 50 + const DEFAULT_SORT = 'metadata.titleSort,asc' const SORT_OPTIONS: Array = [ @@ -593,6 +602,60 @@ export class KomgaExtension implements ExtensionImpl { return chapters } + // Komga has no "changed since" search condition, but /series/updated is + // ordered by lastModified descending, so we can page until we pass the + // cutoff and mark everything untouched as skippable. + async processTitlesForUpdates( + updateManager: UpdateManager, + lastUpdateDate?: Date + ): Promise { + const queued = updateManager.getQueuedItems() + + // With no previous run there is nothing to compare against, so leave the + // app to check everything as it normally would + if (queued.length === 0 || !lastUpdateDate) { + return + } + + const cutoff = new Date(lastUpdateDate.getTime() - UPDATE_CHECK_MARGIN_MS) + const updated = new Set() + + for (let page = 0; page < MAX_UPDATE_PAGES; page++) { + const { data } = await getSeriesUpdated({ + query: { page, size: PAGE_SIZE, deleted: false }, + }).catch(() => ({ data: undefined })) + + // A failed request tells us nothing about what changed. Returning here + // leaves every title at its default priority; marking them skipped would + // silently swallow real updates. + if (!data) { + return + } + + const content = data.content ?? [] + const stale = content.find( + (serie) => new Date(serie.lastModified) <= cutoff + ) + + for (const serie of content) { + if (new Date(serie.lastModified) > cutoff) { + updated.add(serie.id) + } + } + + if (stale || data.last) { + break + } + } + + for (const manga of queued) { + await updateManager.setUpdatePriority( + manga.mangaId, + updated.has(manga.mangaId) ? 'high' : 'skip' + ) + } + } + async getChapterDetails(chapter: Chapter): Promise { const { data, error } = await getBookPages({ path: { bookId: chapter.chapterId }, // diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 45054dd..52cb18e 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.2.0', + version: '3.3.0', name: 'Komga', icon: 'icon.png', developers: [ From ce92b07812802311fe83d849b0afe7a44fc5b04e Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:13:41 -0400 Subject: [PATCH 19/31] feat(Komga): genres discover section, 3.4.0 Adds a Genres section using DiscoverSectionType.genres, which was one of four section types the extension never used. Each tile carries a search query in the shape getSearchResults already reads, so tapping one runs a normal genre search. Hidden genres are left out of the grid when Hide Adult Content is on, and the section has a toggle like the others. --- src/Komga/forms/homepage_settings_form.ts | 15 ++++++++++ src/Komga/komga.ts | 36 +++++++++++++++++++++++ src/Komga/pbconfig.ts | 2 +- src/Komga/utils/config.ts | 10 +++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index eb3017d..4ccd6fb 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -8,10 +8,12 @@ import { getShowContinueReading, getShowOnDeck, getShowRecentlyAdded, + getShowGenres, getShowRecentlyUpdated, setShowContinueReading, setShowOnDeck, setShowRecentlyAdded, + setShowGenres, setShowRecentlyUpdated, } from '../utils/config.js' @@ -56,6 +58,14 @@ export class HomepageSettingsForm extends Form { 'showRecentlyUpdatedDidChange' ), }), + ToggleRow('showGenres', { + title: 'Genres', + value: getShowGenres(), + onValueChange: Application.Selector( + this as HomepageSettingsForm, + 'showGenresDidChange' + ), + }), ] ) } @@ -79,4 +89,9 @@ export class HomepageSettingsForm extends Form { setShowRecentlyUpdated(newValue) Application.invalidateDiscoverSections() } + + async showGenresDidChange(newValue: boolean): Promise { + setShowGenres(newValue) + Application.invalidateDiscoverSections() + } } diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index d54edc3..37194dc 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -64,6 +64,7 @@ import { getShowContinueReading, getShowOnDeck, getShowRecentlyAdded, + getShowGenres, getShowRecentlyUpdated, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' @@ -732,6 +733,14 @@ export class KomgaExtension implements ExtensionImpl { }) } + if (getShowGenres()) { + sections.push({ + id: 'genres', + title: 'Genres', + type: DiscoverSectionType.genres, + }) + } + return sections } @@ -872,6 +881,33 @@ export class KomgaExtension implements ExtensionImpl { metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, } } + case 'genres': { + const genres = await getGenres() + .then((r) => r.data ?? []) + .catch(() => []) + + const hidden = getHideAdultContent() ? getAdultGenres() : [] + + const items: DiscoverSectionItem[] = genres + .filter((genre) => !hidden.includes(genre.toLowerCase())) + .map((genre) => ({ + type: 'genresCarouselItem' as const, + name: capitalize(genre), + // Tapping runs a normal search; the shape here has to match what + // getSearchResults reads out of `metadata` + searchQuery: { + title: '', + metadata: [ + { + id: 'genre', + value: { ['genre-' + btoa(genre)]: 'included' as const }, + }, + ], + }, + })) + + return { items, metadata: undefined } + } default: { throw new Error('Unknown section') } diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 52cb18e..ce1a544 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.3.0', + version: '3.4.0', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 13f5daf..1cd7c40 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -5,6 +5,7 @@ const KEY_SHOW_ON_DECK = 'showOnDeck' const KEY_SHOW_CONTINUE_READING = 'showContinueReading' const KEY_SHOW_RECENTLY_ADDED = 'showRecentlyAdded' const KEY_SHOW_RECENTLY_UPDATED = 'showRecentlyUpdated' +const KEY_SHOW_GENRES = 'showGenres' const KEY_HIDE_ADULT_CONTENT = 'hideAdultContent' const KEY_ADULT_GENRES = 'adultGenres' @@ -15,6 +16,7 @@ const DEFAULT_SHOW_ON_DECK = true const DEFAULT_SHOW_CONTINUE_READING = true const DEFAULT_SHOW_RECENTLY_ADDED = true const DEFAULT_SHOW_RECENTLY_UPDATED = true +const DEFAULT_SHOW_GENRES = true const DEFAULT_HIDE_ADULT_CONTENT = false // Matched case-insensitively against a series' genres const DEFAULT_ADULT_GENRES = [ @@ -113,3 +115,11 @@ export function getAdultGenres(): string[] { export function setAdultGenres(newValue: string[]) { Application.setState(newValue, KEY_ADULT_GENRES) } + +export function getShowGenres() { + return getStateOrDefault(KEY_SHOW_GENRES, DEFAULT_SHOW_GENRES) +} + +export function setShowGenres(newValue: boolean) { + Application.setState(newValue, KEY_SHOW_GENRES) +} From 2ce9016a939299c0a79d1bb16bc816570b5adf06 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:25:58 -0400 Subject: [PATCH 20/31] feat(Komga): featured and prominent discover sections, 3.5.0 Uses the last two unused DiscoverSectionType values. Featured shows recently added series with cover, status, publisher, summary and book counts; the prominent carousel shows in-progress series with read progress. Both overlap sections that already exist, so they default to off rather than crowding the homepage on upgrade, and the toggle subtitles say so. --- src/Komga/forms/homepage_settings_form.ts | 32 ++++++++ src/Komga/komga.ts | 96 +++++++++++++++++++++++ src/Komga/pbconfig.ts | 2 +- src/Komga/utils/config.ts | 20 +++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index 4ccd6fb..aeb5042 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -8,12 +8,16 @@ import { getShowContinueReading, getShowOnDeck, getShowRecentlyAdded, + getShowFeatured, getShowGenres, + getShowProminent, getShowRecentlyUpdated, setShowContinueReading, setShowOnDeck, setShowRecentlyAdded, + setShowFeatured, setShowGenres, + setShowProminent, setShowRecentlyUpdated, } from '../utils/config.js' @@ -58,6 +62,24 @@ export class HomepageSettingsForm extends Form { 'showRecentlyUpdatedDidChange' ), }), + ToggleRow('showFeatured', { + title: 'Featured', + subtitle: 'Large cards for recently added series', + value: getShowFeatured(), + onValueChange: Application.Selector( + this as HomepageSettingsForm, + 'showFeaturedDidChange' + ), + }), + ToggleRow('showProminent', { + title: 'Pick Up Where You Left Off', + subtitle: 'Overlaps Continue Reading', + value: getShowProminent(), + onValueChange: Application.Selector( + this as HomepageSettingsForm, + 'showProminentDidChange' + ), + }), ToggleRow('showGenres', { title: 'Genres', value: getShowGenres(), @@ -90,6 +112,16 @@ export class HomepageSettingsForm extends Form { Application.invalidateDiscoverSections() } + async showFeaturedDidChange(newValue: boolean): Promise { + setShowFeatured(newValue) + Application.invalidateDiscoverSections() + } + + async showProminentDidChange(newValue: boolean): Promise { + setShowProminent(newValue) + Application.invalidateDiscoverSections() + } + async showGenresDidChange(newValue: boolean): Promise { setShowGenres(newValue) Application.invalidateDiscoverSections() diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 37194dc..042f0ed 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -64,7 +64,9 @@ import { getShowContinueReading, getShowOnDeck, getShowRecentlyAdded, + getShowFeatured, getShowGenres, + getShowProminent, getShowRecentlyUpdated, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' @@ -698,6 +700,14 @@ export class KomgaExtension implements ExtensionImpl { async getDiscoverSections(): Promise { const sections: DiscoverSection[] = [] + if (getShowFeatured()) { + sections.push({ + id: 'featured', + title: 'Featured', + type: DiscoverSectionType.featured, + }) + } + const showOnDeck = getShowOnDeck() const showContinueReading = getShowContinueReading() @@ -733,6 +743,14 @@ export class KomgaExtension implements ExtensionImpl { }) } + if (getShowProminent()) { + sections.push({ + id: 'prominent', + title: 'Pick Up Where You Left Off', + type: DiscoverSectionType.prominentCarousel, + }) + } + if (getShowGenres()) { sections.push({ id: 'genres', @@ -881,6 +899,84 @@ export class KomgaExtension implements ExtensionImpl { metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, } } + case 'featured': { + const { data, error } = await getSeriesNew({ + query: { page: metadata?.page, deleted: false }, + }) + + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const items: DiscoverSectionItem[] = [] + for (const serie of data.content ?? []) { + if (isHiddenSeries(serie.metadata)) { + continue + } + + items.push({ + type: 'featuredCarouselItem', + mangaId: serie.id, + title: serie.metadata.title, + imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, + supertitle: [ + parseMangaStatus(serie.metadata.status).toUpperCase(), + serie.metadata.publisher, + ] + .filter(Boolean) + .join(' \u00b7 '), + summary: serie.metadata.summary, + infoItems: [ + { symbol: 'book.fill', text: `${serie.booksCount}` }, + { + symbol: 'checkmark.circle.fill', + text: `${serie.booksReadCount} read`, + }, + ], + contentRating: parseContentRating(serie.metadata), + }) + } + + return { + items, + metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, + } + } + case 'prominent': { + const { data, error } = await getSeriesList({ + query: { sort: ['readProgress.readDate,desc'], page: metadata?.page }, + body: { + condition: { + allOf: [ + { deleted: isFalse() }, + { readStatus: isEqualTo('IN_PROGRESS') }, + ...hiddenGenreConditions(), + ], + }, + }, + }) + + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const items: DiscoverSectionItem[] = [] + for (const serie of data.content ?? []) { + items.push({ + type: 'prominentCarouselItem', + mangaId: serie.id, + title: serie.name, + imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, + subtitle: `${serie.booksReadCount} of ${serie.booksCount} read`, + contentRating: parseContentRating(serie.metadata), + }) + } + + return { + items, + metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, + } + } case 'genres': { const genres = await getGenres() .then((r) => r.data ?? []) diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index ce1a544..9f6d966 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.4.0', + version: '3.5.0', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 1cd7c40..0082428 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -5,6 +5,8 @@ const KEY_SHOW_ON_DECK = 'showOnDeck' const KEY_SHOW_CONTINUE_READING = 'showContinueReading' const KEY_SHOW_RECENTLY_ADDED = 'showRecentlyAdded' const KEY_SHOW_RECENTLY_UPDATED = 'showRecentlyUpdated' +const KEY_SHOW_FEATURED = 'showFeatured' +const KEY_SHOW_PROMINENT = 'showProminent' const KEY_SHOW_GENRES = 'showGenres' const KEY_HIDE_ADULT_CONTENT = 'hideAdultContent' const KEY_ADULT_GENRES = 'adultGenres' @@ -16,6 +18,8 @@ const DEFAULT_SHOW_ON_DECK = true const DEFAULT_SHOW_CONTINUE_READING = true const DEFAULT_SHOW_RECENTLY_ADDED = true const DEFAULT_SHOW_RECENTLY_UPDATED = true +const DEFAULT_SHOW_FEATURED = false +const DEFAULT_SHOW_PROMINENT = false const DEFAULT_SHOW_GENRES = true const DEFAULT_HIDE_ADULT_CONTENT = false // Matched case-insensitively against a series' genres @@ -123,3 +127,19 @@ export function getShowGenres() { export function setShowGenres(newValue: boolean) { Application.setState(newValue, KEY_SHOW_GENRES) } + +export function getShowFeatured() { + return getStateOrDefault(KEY_SHOW_FEATURED, DEFAULT_SHOW_FEATURED) +} + +export function setShowFeatured(newValue: boolean) { + Application.setState(newValue, KEY_SHOW_FEATURED) +} + +export function getShowProminent() { + return getStateOrDefault(KEY_SHOW_PROMINENT, DEFAULT_SHOW_PROMINENT) +} + +export function setShowProminent(newValue: boolean) { + Application.setState(newValue, KEY_SHOW_PROMINENT) +} From f48ec69747e31695644f243e5b445044c62ba9f9 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:48:21 -0400 Subject: [PATCH 21/31] feat(Komga): filter On Deck and record chapter creation date, 3.6.0 On Deck was the one section the content filter could not reach, since Komga puts genres on series and that endpoint returns books. Fetching the hidden series once and filtering by membership covers it in a single extra request, and only when the filter is enabled. The settings footer no longer claims the section is unfilterable. Chapter.creationDate now carries the book's created time, which is when it entered the library rather than when it was published. --- src/Komga/forms/content_settings_form.ts | 2 +- src/Komga/komga.ts | 27 ++++++++++++++++++++++++ src/Komga/pbconfig.ts | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Komga/forms/content_settings_form.ts b/src/Komga/forms/content_settings_form.ts index 058b30c..bbf3fe7 100644 --- a/src/Komga/forms/content_settings_form.ts +++ b/src/Komga/forms/content_settings_form.ts @@ -44,7 +44,7 @@ export class ContentSettingsForm extends Form { { id: 'adultContent', footer: - 'Hides series whose genres match the list below. Komga rarely sets an age rating, so this matches on genres instead. On Deck cannot be filtered because Komga does not expose genres on books.', + 'Hides series whose genres match the list below. Komga rarely sets an age rating, so this matches on genres instead.', }, [ ToggleRow('hideAdultContent', { diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 042f0ed..d9e0d51 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -183,6 +183,25 @@ const hiddenGenreConditions = () => { return getAdultGenres().map((genre) => ({ genre: isNotEqualTo(genre) })) } +// On Deck returns books, and Komga puts genres only on series. Rather than a +// lookup per book, fetch the hidden series once and filter by membership. +const hiddenSeriesIds = async (): Promise> => { + if (!getHideAdultContent()) { + return new Set() + } + + const { data } = await getSeriesList({ + query: { unpaged: true }, + body: { + condition: { + anyOf: getAdultGenres().map((genre) => ({ genre: isEqualTo(genre) })), + }, + }, + }).catch(() => ({ data: undefined })) + + return new Set((data?.content ?? []).map((serie) => serie.id)) +} + const isHiddenSeries = (metadata: { genres?: Array }): boolean => { if (!getHideAdultContent()) { return false @@ -597,6 +616,8 @@ export class KomgaExtension implements ExtensionImpl { publishDate: book.metadata.releaseDate ? new Date(book.metadata.releaseDate) : new Date(book.fileLastModified), + // When the book landed in the library, as opposed to when it published + creationDate: new Date(book.created), sortingIndex: book.metadata.numberSort, sourceManga: sourceManga, }) @@ -776,8 +797,14 @@ export class KomgaExtension implements ExtensionImpl { throw new Error(JSON.stringify(error, undefined, 2)) } + const hidden = await hiddenSeriesIds() + const items: DiscoverSectionItem[] = [] for (const serie of data.content ?? []) { + if (hidden.has(serie.seriesId)) { + continue + } + const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/books/${serie.id}/thumbnail` items.push({ diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 9f6d966..e319a8d 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.5.0', + version: '3.6.0', name: 'Komga', icon: 'icon.png', developers: [ From 33a8d6a4f92e381bc88136602e5cebd5077dd9ab Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:55:47 -0400 Subject: [PATCH 22/31] fix(Komga): name the featured section for its content, 3.6.1 "Featured" described the card style rather than what the section shows. The id and settings key stay as they are so existing toggle state survives. --- src/Komga/forms/homepage_settings_form.ts | 4 ++-- src/Komga/komga.ts | 4 +++- src/Komga/pbconfig.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index aeb5042..34b7435 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -63,8 +63,8 @@ export class HomepageSettingsForm extends Form { ), }), ToggleRow('showFeatured', { - title: 'Featured', - subtitle: 'Large cards for recently added series', + title: 'New Arrivals', + subtitle: 'Large cards, same series as Recently Added', value: getShowFeatured(), onValueChange: Application.Selector( this as HomepageSettingsForm, diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index d9e0d51..bc28aed 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -723,8 +723,10 @@ export class KomgaExtension implements ExtensionImpl { if (getShowFeatured()) { sections.push({ + // id and settings key stay `featured` so existing toggle state + // survives; the title describes the content, not the card style id: 'featured', - title: 'Featured', + title: 'New Arrivals', type: DiscoverSectionType.featured, }) } diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index e319a8d..e5ee8e6 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.6.0', + version: '3.6.1', name: 'Komga', icon: 'icon.png', developers: [ From 9bb6569feff001aba75ff0a6ba9551a4efbc9b44 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:12:00 -0400 Subject: [PATCH 23/31] feat(Komga): merge duplicate sections into a per-section style, 3.7.0 Seven homepage sections were backed by five queries: Featured and Recently Added both called /series/new, and the prominent carousel and Continue Reading both called /series/list with the same condition and sort. Presentation is now a property of each section rather than a reason to duplicate it, chosen per section as Regular, Large or Hero, with Hidden replacing the old visibility toggles. Labels follow Komga's own dashboard vocabulary so they read the way users already know them from the web UI: Continue Reading becomes Keep Reading, and the two series sections name themselves as such. Existing settings migrate rather than reset. An old visibility boolean maps to Regular or Hidden, and a duplicate section that was switched on becomes its owner's style. --- src/Komga/discover_sections.ts | 38 +++ src/Komga/forms/homepage_settings_form.ts | 168 ++++------- src/Komga/komga.ts | 342 +++++++--------------- src/Komga/pbconfig.ts | 2 +- src/Komga/utils/config.ts | 117 ++++---- 5 files changed, 255 insertions(+), 412 deletions(-) create mode 100644 src/Komga/discover_sections.ts diff --git a/src/Komga/discover_sections.ts b/src/Komga/discover_sections.ts new file mode 100644 index 0000000..1ac1c04 --- /dev/null +++ b/src/Komga/discover_sections.ts @@ -0,0 +1,38 @@ +// Labels follow Komga's own dashboard vocabulary (komga-webui en.json) so the +// sections read the same way they do in the Komga web UI. +export interface DiscoverSectionDefinition { + id: string + title: string + description: string + // Sections whose card layout is dictated by their content, so only on/off + fixedStyle?: boolean +} + +export const DISCOVER_SECTIONS: DiscoverSectionDefinition[] = [ + { + id: 'onDeck', + title: 'On Deck', + description: 'The next unread book in series you have started', + }, + { + id: 'keepReading', + title: 'Keep Reading', + description: 'Series you are part way through', + }, + { + id: 'recentlyAdded', + title: 'Recently Added Series', + description: 'Series most recently added to the library', + }, + { + id: 'recentlyUpdated', + title: 'Recently Updated Series', + description: 'Series changed most recently, including metadata edits', + }, + { + id: 'genres', + title: 'Genres', + description: 'Browse the genres in your library', + fixedStyle: true, + }, +] diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index 34b7435..b1d2c39 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -1,129 +1,71 @@ import { + closureSelector, Form, Section, + SelectRow, ToggleRow, type FormSectionElement, } from '@paperback/types' import { - getShowContinueReading, - getShowOnDeck, - getShowRecentlyAdded, - getShowFeatured, - getShowGenres, - getShowProminent, - getShowRecentlyUpdated, - setShowContinueReading, - setShowOnDeck, - setShowRecentlyAdded, - setShowFeatured, - setShowGenres, - setShowProminent, - setShowRecentlyUpdated, + getSectionStyle, + SECTION_STYLES, + setSectionStyle, + type SectionStyle, } from '../utils/config.js' +import { DISCOVER_SECTIONS } from '../discover_sections.js' export class HomepageSettingsForm extends Form { override getSections(): FormSectionElement[] { - return [this.staticHomepageSection()] + return [ + Section( + { + id: 'sections', + header: 'Homepage Sections', + footer: + 'Regular, Large and Hero change how a section presents its covers. Hidden removes it from the homepage.', + }, + // A row handler takes only its new value, so each row closes over the + // section it belongs to rather than needing a method per section + DISCOVER_SECTIONS.map((section) => + section.fixedStyle + ? ToggleRow(section.id, { + title: section.title, + subtitle: section.description, + value: getSectionStyle(section.id) !== 'hidden', + onValueChange: closureSelector( + this, + `visible_${section.id}`, + async (value: boolean) => { + this.applyStyle(section.id, value ? 'simple' : 'hidden') + } + ), + }) + : SelectRow(section.id, { + title: section.title, + subtitle: section.description, + value: [getSectionStyle(section.id)], + minItemCount: 1, + maxItemCount: 1, + layout: 'list', + items: SECTION_STYLES, + onValueChange: closureSelector( + this, + `style_${section.id}`, + async (value: string[]) => { + const style = value[0] + if (style) { + this.applyStyle(section.id, style as SectionStyle) + } + } + ), + }) + ) + ), + ] } - staticHomepageSection(): FormSectionElement { - return Section( - { id: 'staticHomepageSection', header: 'Static Homepage Sections' }, - [ - ToggleRow('showOnDeck', { - title: 'On Deck', - value: getShowOnDeck(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showOnDeckDidChange' - ), - }), - ToggleRow('showContinueReading', { - title: 'Continue Reading', - value: getShowContinueReading(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showContinueReadingDidChange' - ), - }), - ToggleRow('showRecentlyAdded', { - title: 'Recently Added', - value: getShowRecentlyAdded(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showRecentlyAddedDidChange' - ), - }), - ToggleRow('showRecentlyUpdated', { - title: 'Recently Updated', - value: getShowRecentlyUpdated(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showRecentlyUpdatedDidChange' - ), - }), - ToggleRow('showFeatured', { - title: 'New Arrivals', - subtitle: 'Large cards, same series as Recently Added', - value: getShowFeatured(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showFeaturedDidChange' - ), - }), - ToggleRow('showProminent', { - title: 'Pick Up Where You Left Off', - subtitle: 'Overlaps Continue Reading', - value: getShowProminent(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showProminentDidChange' - ), - }), - ToggleRow('showGenres', { - title: 'Genres', - value: getShowGenres(), - onValueChange: Application.Selector( - this as HomepageSettingsForm, - 'showGenresDidChange' - ), - }), - ] - ) - } - - async showOnDeckDidChange(newValue: boolean): Promise { - setShowOnDeck(newValue) - Application.invalidateDiscoverSections() - } - - async showContinueReadingDidChange(newValue: boolean): Promise { - setShowContinueReading(newValue) - Application.invalidateDiscoverSections() - } - - async showRecentlyAddedDidChange(newValue: boolean): Promise { - setShowRecentlyAdded(newValue) - Application.invalidateDiscoverSections() - } - - async showRecentlyUpdatedDidChange(newValue: boolean): Promise { - setShowRecentlyUpdated(newValue) - Application.invalidateDiscoverSections() - } - - async showFeaturedDidChange(newValue: boolean): Promise { - setShowFeatured(newValue) - Application.invalidateDiscoverSections() - } - - async showProminentDidChange(newValue: boolean): Promise { - setShowProminent(newValue) - Application.invalidateDiscoverSections() - } - - async showGenresDidChange(newValue: boolean): Promise { - setShowGenres(newValue) + private applyStyle(sectionId: string, style: SectionStyle): void { + setSectionStyle(sectionId, style) Application.invalidateDiscoverSections() } } diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index bc28aed..c421b88 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -61,15 +61,11 @@ import { getHideAdultContent, getKomgaBaseURL, getKomgaCredentials, - getShowContinueReading, - getShowOnDeck, - getShowRecentlyAdded, - getShowFeatured, - getShowGenres, - getShowProminent, - getShowRecentlyUpdated, + getSectionStyle, + type SectionStyle, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' +import { DISCOVER_SECTIONS } from './discover_sections.js' import { parseChapterTitle } from './utils/titles.js' import { ProgressManagementForm } from './forms/progress_management_form.js' import type KomgaConfig from './pbconfig.js' @@ -135,6 +131,15 @@ const UPDATE_CHECK_MARGIN_MS = 60 * 60 * 1000 // Guards the paging loop; far more than a sane library needs in one pass const MAX_UPDATE_PAGES = 50 +const STYLE_TO_SECTION_TYPE: Record< + Exclude, + DiscoverSectionType +> = { + simple: DiscoverSectionType.simpleCarousel, + large: DiscoverSectionType.prominentCarousel, + hero: DiscoverSectionType.featured, +} + const DEFAULT_SORT = 'metadata.titleSort,asc' const SORT_OPTIONS: Array = [ @@ -721,111 +726,123 @@ export class KomgaExtension implements ExtensionImpl { async getDiscoverSections(): Promise { const sections: DiscoverSection[] = [] - if (getShowFeatured()) { - sections.push({ - // id and settings key stay `featured` so existing toggle state - // survives; the title describes the content, not the card style - id: 'featured', - title: 'New Arrivals', - type: DiscoverSectionType.featured, - }) - } - - const showOnDeck = getShowOnDeck() - const showContinueReading = getShowContinueReading() - - if (showOnDeck) { - sections.push({ - id: 'showOnDeck', - title: 'On Deck', - type: DiscoverSectionType.simpleCarousel, - }) - } - - if (showContinueReading) { - sections.push({ - id: 'continueReading', - title: 'Continue Reading', - type: DiscoverSectionType.simpleCarousel, - }) - } + for (const definition of DISCOVER_SECTIONS) { + const style = getSectionStyle(definition.id) + if (style === 'hidden') { + continue + } - if (getShowRecentlyAdded()) { sections.push({ - id: 'recentlyAdded', - title: 'Recently Added', - type: DiscoverSectionType.simpleCarousel, + id: definition.id, + title: definition.title, + type: definition.fixedStyle + ? DiscoverSectionType.genres + : STYLE_TO_SECTION_TYPE[style], }) } - if (getShowRecentlyUpdated()) { - sections.push({ - id: 'recentlyUpdated', - title: 'Recently Updated', - type: DiscoverSectionType.simpleCarousel, - }) - } + return sections + } - if (getShowProminent()) { - sections.push({ - id: 'prominent', - title: 'Pick Up Where You Left Off', - type: DiscoverSectionType.prominentCarousel, - }) + // One builder per source shape, so a section's style only changes how its + // covers are presented rather than duplicating the query + private seriesItem( + serie: { + id: string + name: string + booksCount: number + booksReadCount: number + metadata: { + title: string + status: string + publisher: string + summary: string + genres?: Array + ageRating?: number + } + }, + style: SectionStyle + ): DiscoverSectionItem { + const base = { + mangaId: serie.id, + title: serie.metadata.title || serie.name, + imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, + contentRating: parseContentRating(serie.metadata), + } + + if (style === 'hero') { + return { + type: 'featuredCarouselItem', + ...base, + supertitle: [ + parseMangaStatus(serie.metadata.status).toUpperCase(), + serie.metadata.publisher, + ] + .filter(Boolean) + .join(' \u00b7 '), + summary: serie.metadata.summary, + infoItems: [ + { symbol: 'book.fill', text: `${serie.booksCount}` }, + { + symbol: 'checkmark.circle.fill', + text: `${serie.booksReadCount} read`, + }, + ], + } } - if (getShowGenres()) { - sections.push({ - id: 'genres', - title: 'Genres', - type: DiscoverSectionType.genres, - }) - } + const subtitle = + serie.booksCount > 0 + ? `${serie.booksReadCount} of ${serie.booksCount} read` + : undefined - return sections + return style === 'large' + ? { type: 'prominentCarouselItem', ...base, subtitle } + : { type: 'simpleCarouselItem', ...base, subtitle: undefined } } async getDiscoverSectionItems( section: DiscoverSection, metadata: { page: number } | undefined ): Promise> { - switch (section.id) { - case 'showOnDeck': { - const { data, error } = await getBooksOnDeck({ - query: { page: metadata?.page }, - }) + const style = getSectionStyle(section.id) + const page = metadata?.page + switch (section.id) { + case 'onDeck': { + const { data, error } = await getBooksOnDeck({ query: { page } }) if (!data) { throw new Error(JSON.stringify(error, undefined, 2)) } const hidden = await hiddenSeriesIds() - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - if (hidden.has(serie.seriesId)) { + + for (const book of data.content ?? []) { + if (hidden.has(book.seriesId)) { continue } - const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/books/${serie.id}/thumbnail` - items.push({ - type: 'simpleCarouselItem', - title: serie.seriesTitle, - imageUrl: thumbnailUrl, - mangaId: serie.seriesId, - subtitle: undefined, + type: + style === 'large' + ? 'prominentCarouselItem' + : 'simpleCarouselItem', + mangaId: book.seriesId, + title: book.seriesTitle, + subtitle: book.metadata.title, + imageUrl: `${client.getConfig().baseUrl}/api/v1/books/${book.id}/thumbnail`, }) } return { items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, } } - case 'continueReading': { + case 'keepReading': { const { data, error } = await getSeriesList({ - query: { sort: ['readProgress.readDate,desc'], page: metadata?.page }, + query: { sort: ['readProgress.readDate,desc'], page }, body: { condition: { allOf: [ @@ -836,174 +853,35 @@ export class KomgaExtension implements ExtensionImpl { }, }, }) - if (!data) { throw new Error(JSON.stringify(error, undefined, 2)) } - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - if (isHiddenSeries(serie.metadata)) { - continue - } - - const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` - - items.push({ - type: 'simpleCarouselItem', - title: serie.name, - imageUrl: thumbnailUrl, - mangaId: serie.id, - subtitle: undefined, - contentRating: parseContentRating(serie.metadata), - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - case 'recentlyAdded': { - const { data, error } = await getSeriesNew({ - query: { page: metadata?.page, deleted: false }, - }) - - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - if (isHiddenSeries(serie.metadata)) { - continue - } - - const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` - - items.push({ - type: 'simpleCarouselItem', - title: serie.name, - imageUrl: thumbnailUrl, - mangaId: serie.id, - subtitle: undefined, - contentRating: parseContentRating(serie.metadata), - }) - } - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, + items: (data.content ?? []).map((serie) => + this.seriesItem(serie, style) + ), + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, } } + case 'recentlyAdded': case 'recentlyUpdated': { - const { data, error } = await getSeriesUpdated({ - query: { page: metadata?.page, deleted: false }, - }) - - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - if (isHiddenSeries(serie.metadata)) { - continue - } - - const thumbnailUrl = `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail` - - items.push({ - type: 'simpleCarouselItem', - title: serie.name, - imageUrl: thumbnailUrl, - mangaId: serie.id, - subtitle: undefined, - contentRating: parseContentRating(serie.metadata), - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - case 'featured': { - const { data, error } = await getSeriesNew({ - query: { page: metadata?.page, deleted: false }, + const fetch = + section.id === 'recentlyAdded' ? getSeriesNew : getSeriesUpdated + const { data, error } = await fetch({ + query: { page, deleted: false }, }) - if (!data) { throw new Error(JSON.stringify(error, undefined, 2)) } - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - if (isHiddenSeries(serie.metadata)) { - continue - } - - items.push({ - type: 'featuredCarouselItem', - mangaId: serie.id, - title: serie.metadata.title, - imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, - supertitle: [ - parseMangaStatus(serie.metadata.status).toUpperCase(), - serie.metadata.publisher, - ] - .filter(Boolean) - .join(' \u00b7 '), - summary: serie.metadata.summary, - infoItems: [ - { symbol: 'book.fill', text: `${serie.booksCount}` }, - { - symbol: 'checkmark.circle.fill', - text: `${serie.booksReadCount} read`, - }, - ], - contentRating: parseContentRating(serie.metadata), - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - case 'prominent': { - const { data, error } = await getSeriesList({ - query: { sort: ['readProgress.readDate,desc'], page: metadata?.page }, - body: { - condition: { - allOf: [ - { deleted: isFalse() }, - { readStatus: isEqualTo('IN_PROGRESS') }, - ...hiddenGenreConditions(), - ], - }, - }, - }) - - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - items.push({ - type: 'prominentCarouselItem', - mangaId: serie.id, - title: serie.name, - imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, - subtitle: `${serie.booksReadCount} of ${serie.booksCount} read`, - contentRating: parseContentRating(serie.metadata), - }) - } + const items = (data.content ?? []) + .filter((serie) => !isHiddenSeries(serie.metadata)) + .map((serie) => this.seriesItem(serie, style)) return { items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, } } case 'genres': { @@ -1018,8 +896,6 @@ export class KomgaExtension implements ExtensionImpl { .map((genre) => ({ type: 'genresCarouselItem' as const, name: capitalize(genre), - // Tapping runs a normal search; the shape here has to match what - // getSearchResults reads out of `metadata` searchQuery: { title: '', metadata: [ @@ -1034,7 +910,7 @@ export class KomgaExtension implements ExtensionImpl { return { items, metadata: undefined } } default: { - throw new Error('Unknown section') + throw new Error(`Unknown section ${section.id}`) } } } diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index e5ee8e6..c1e8915 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.6.1', + version: '3.7.0', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 0082428..b4ed3c4 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -1,26 +1,12 @@ const KEY_KOMGA_BASE_URL = 'serverURL' const KEY_KOMGA_USERNAME = 'serverUsername' const KEY_KOMGA_PASSWORD = 'serverPassword' -const KEY_SHOW_ON_DECK = 'showOnDeck' -const KEY_SHOW_CONTINUE_READING = 'showContinueReading' -const KEY_SHOW_RECENTLY_ADDED = 'showRecentlyAdded' -const KEY_SHOW_RECENTLY_UPDATED = 'showRecentlyUpdated' -const KEY_SHOW_FEATURED = 'showFeatured' -const KEY_SHOW_PROMINENT = 'showProminent' -const KEY_SHOW_GENRES = 'showGenres' const KEY_HIDE_ADULT_CONTENT = 'hideAdultContent' const KEY_ADULT_GENRES = 'adultGenres' const DEFAULT_KOMGA_BASE_URL = 'https://demo.komga.org' const DEFAULT_KOMGA_USERNAME = 'demo@komga.org' const DEFAULT_KOMGA_PASSWORD = 'komga-demo' -const DEFAULT_SHOW_ON_DECK = true -const DEFAULT_SHOW_CONTINUE_READING = true -const DEFAULT_SHOW_RECENTLY_ADDED = true -const DEFAULT_SHOW_RECENTLY_UPDATED = true -const DEFAULT_SHOW_FEATURED = false -const DEFAULT_SHOW_PROMINENT = false -const DEFAULT_SHOW_GENRES = true const DEFAULT_HIDE_ADULT_CONTENT = false // Matched case-insensitively against a series' genres const DEFAULT_ADULT_GENRES = [ @@ -61,44 +47,6 @@ export function setKomgaCredentials(username: string, password: string) { Application.setSecureState(password, KEY_KOMGA_PASSWORD) } -export function getShowOnDeck() { - return getStateOrDefault(KEY_SHOW_ON_DECK, DEFAULT_SHOW_ON_DECK) -} - -export function setShowOnDeck(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_ON_DECK) -} - -export function getShowContinueReading() { - return getStateOrDefault( - KEY_SHOW_CONTINUE_READING, - DEFAULT_SHOW_CONTINUE_READING - ) -} - -export function setShowContinueReading(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_CONTINUE_READING) -} - -export function getShowRecentlyAdded() { - return getStateOrDefault(KEY_SHOW_RECENTLY_ADDED, DEFAULT_SHOW_RECENTLY_ADDED) -} - -export function setShowRecentlyAdded(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_RECENTLY_ADDED) -} - -export function getShowRecentlyUpdated() { - return getStateOrDefault( - KEY_SHOW_RECENTLY_UPDATED, - DEFAULT_SHOW_RECENTLY_UPDATED - ) -} - -export function setShowRecentlyUpdated(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_RECENTLY_UPDATED) -} - export function getHideAdultContent() { return getStateOrDefault(KEY_HIDE_ADULT_CONTENT, DEFAULT_HIDE_ADULT_CONTENT) } @@ -120,26 +68,65 @@ export function setAdultGenres(newValue: string[]) { Application.setState(newValue, KEY_ADULT_GENRES) } -export function getShowGenres() { - return getStateOrDefault(KEY_SHOW_GENRES, DEFAULT_SHOW_GENRES) -} +// How a discover section is presented. `hidden` drops it entirely. +export type SectionStyle = 'hidden' | 'simple' | 'large' | 'hero' + +export const SECTION_STYLES: Array<{ id: SectionStyle; title: string }> = [ + { id: 'hidden', title: 'Hidden' }, + { id: 'simple', title: 'Regular' }, + { id: 'large', title: 'Large' }, + { id: 'hero', title: 'Hero' }, +] + +const styleKey = (sectionId: string) => `sectionStyle.${sectionId}` -export function setShowGenres(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_GENRES) +// Earlier versions stored a boolean per section plus two extra sections that +// only differed in presentation. Fold those into the style setting so nobody's +// configuration resets on upgrade. +const LEGACY_VISIBILITY: Record = { + onDeck: 'showOnDeck', + keepReading: 'showContinueReading', + recentlyAdded: 'showRecentlyAdded', + recentlyUpdated: 'showRecentlyUpdated', + genres: 'showGenres', } -export function getShowFeatured() { - return getStateOrDefault(KEY_SHOW_FEATURED, DEFAULT_SHOW_FEATURED) +const LEGACY_STYLE_SECTION: Record = { + showFeatured: 'hero', + showProminent: 'large', } -export function setShowFeatured(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_FEATURED) +const LEGACY_STYLE_OWNER: Record = { + showFeatured: 'recentlyAdded', + showProminent: 'keepReading', } -export function getShowProminent() { - return getStateOrDefault(KEY_SHOW_PROMINENT, DEFAULT_SHOW_PROMINENT) +export function getSectionStyle(sectionId: string): SectionStyle { + const stored = Application.getState(styleKey(sectionId)) + if (typeof stored === 'string') { + return stored as SectionStyle + } + + // A duplicate section that was switched on becomes its owner's style + for (const [legacyKey, style] of Object.entries(LEGACY_STYLE_SECTION)) { + if ( + LEGACY_STYLE_OWNER[legacyKey] === sectionId && + Application.getState(legacyKey) === true + ) { + return style + } + } + + const legacyVisibility = LEGACY_VISIBILITY[sectionId] + if (legacyVisibility !== undefined) { + return Application.getState(legacyVisibility) === false + ? 'hidden' + : 'simple' + } + + return 'simple' } -export function setShowProminent(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_PROMINENT) +export function setSectionStyle(sectionId: string, style: SectionStyle) { + Application.setState(style, styleKey(sectionId)) } From acc7137017d34e4022de80a439194cc33fa166bd Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:22:17 -0400 Subject: [PATCH 24/31] feat(Komga): show connection status on the settings screen, 3.8.0 The status was only visible after pressing Test Connection a level down. The root settings screen now checks on appearance and shows the result, with the server URL as the Server Settings subtitle. The check moves into a shared helper that Test Connection and form submission both use, replacing two copies of the same error handling. It also no longer trusts a bare 200: Komga's SPA answers unknown paths with index.html, so a base URL with a stray path looked like a successful connection and reported "Connected as undefined". --- src/Komga/forms/server_settings_form.ts | 93 ++++--------------------- src/Komga/forms/settings_form.ts | 24 +++++++ src/Komga/pbconfig.ts | 2 +- src/Komga/utils/connection.ts | 57 +++++++++++++++ 4 files changed, 96 insertions(+), 80 deletions(-) create mode 100644 src/Komga/utils/connection.ts diff --git a/src/Komga/forms/server_settings_form.ts b/src/Komga/forms/server_settings_form.ts index fe63405..b253aa9 100644 --- a/src/Komga/forms/server_settings_form.ts +++ b/src/Komga/forms/server_settings_form.ts @@ -13,7 +13,7 @@ import { setKomgaBaseURL, setKomgaCredentials, } from '../utils/config.js' -import { getCurrentUser } from '../sdk/sdk.gen.js' +import { checkKomgaConnection } from '../utils/connection.js' export class ServerSettingsForm extends Form { baseUrl = getKomgaBaseURL() @@ -102,50 +102,14 @@ export class ServerSettingsForm extends Form { this.connectionStatus = { text: 'Checking...', style: 'tinted' } this.reloadForm() - try { - const { data, error, response } = await getCurrentUser({ - baseUrl: this.baseUrl, - auth: (auth) => - auth.scheme === 'basic' - ? `${this.credentials.username}:${this.credentials.password}` - : undefined, - }) - - if (!error && data) { - this.connectionStatus = { - text: `Connected as ${data.email}`, - style: 'success', - } - } else { - this.connectionStatus = { - text: this.describeFailure(response?.status), - style: 'error', - } - } - } catch { - this.connectionStatus = { - text: `Could not reach ${this.baseUrl}`, - style: 'error', - } - } - + const { ok, message } = await checkKomgaConnection( + this.baseUrl, + this.credentials + ) + this.connectionStatus = { text: message, style: ok ? 'success' : 'error' } this.reloadForm() } - private describeFailure(status: number | undefined): string { - switch (status) { - case undefined: - return `Could not reach ${this.baseUrl}` - case 401: - case 403: - return 'Invalid credentials' - case 404: - return 'No Komga server at that URL' - default: - return `Server returned ${status}` - } - } - override getSections(): FormSectionElement[] { return [ this.baseUrlSection(), @@ -158,45 +122,16 @@ export class ServerSettingsForm extends Form { override requiresExplicitSubmission: boolean = true override async formDidSubmit(): Promise { - const { error, response } = await getCurrentUser({ - baseUrl: this.baseUrl, - auth: (auth) => { - if (auth.scheme === 'basic') { - return `${this.credentials.username}:${this.credentials.password}` - } else { - return undefined - } - }, - }) + const { ok, message } = await checkKomgaConnection( + this.baseUrl, + this.credentials + ) - if (!error) { - setKomgaBaseURL(this.baseUrl) - setKomgaCredentials(this.credentials.username, this.credentials.password) - return + if (!ok) { + throw new Error(message) } - // Only a 400 is typed as carrying `violations`; every other failure (401, - // 5xx, an unreachable host) has no such field, so reaching for it here - // threw a TypeError instead of showing the real problem. - switch (response?.status) { - case undefined: { - throw new Error(`Could not reach ${this.baseUrl}. Check the URL.`) - } - case 401: - case 403: { - throw new Error(`Error ${response.status}: invalid credentials`) - } - case 404: { - throw new Error( - `Error 404: no Komga server at ${this.baseUrl}. Check the URL.` - ) - } - default: { - const violations = error?.violations?.map((x) => x.message).join('\n') - throw new Error( - `Error ${response.status}${violations ? `: ${violations}` : ''}` - ) - } - } + setKomgaBaseURL(this.baseUrl) + setKomgaCredentials(this.credentials.username, this.credentials.password) } } diff --git a/src/Komga/forms/settings_form.ts b/src/Komga/forms/settings_form.ts index f9afaca..da2e3af 100644 --- a/src/Komga/forms/settings_form.ts +++ b/src/Komga/forms/settings_form.ts @@ -1,19 +1,43 @@ import { Form, + LabelRow, NavigationRow, Section, type FormSectionElement, + type LabelRowValue, } from '@paperback/types' import { ServerSettingsForm } from './server_settings_form.js' import { HomepageSettingsForm } from './homepage_settings_form.js' import { ContentSettingsForm } from './content_settings_form.js' +import { getKomgaBaseURL } from '../utils/config.js' +import { checkKomgaConnection } from '../utils/connection.js' export class SettingsForm extends Form { + private status: LabelRowValue = { text: 'Checking...', style: 'tinted' } + + override formWillAppear(): void { + void this.refreshStatus() + } + + private async refreshStatus(): Promise { + const { ok, message } = await checkKomgaConnection() + this.status = { text: message, style: ok ? 'success' : 'error' } + this.reloadForm() + } + override getSections(): FormSectionElement[] { return [ + Section({ id: 'status', header: 'Server' }, [ + LabelRow('connection', { + title: 'Status', + value: this.status, + }), + ]), + Section('authentication', [ NavigationRow('authentication', { title: 'Server Settings', + subtitle: getKomgaBaseURL(), form: new ServerSettingsForm(), }), ]), diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index c1e8915..23d46d6 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.7.0', + version: '3.8.0', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/connection.ts b/src/Komga/utils/connection.ts new file mode 100644 index 0000000..7712f9a --- /dev/null +++ b/src/Komga/utils/connection.ts @@ -0,0 +1,57 @@ +import { getCurrentUser } from '../sdk/index.js' +import { getKomgaBaseURL, getKomgaCredentials } from './config.js' + +export interface ConnectionStatus { + ok: boolean + message: string +} + +// Only a 400 is typed as carrying `violations`, so every other failure needs a +// message of its own rather than reaching into the error body. +function describeFailure(baseUrl: string, status: number | undefined): string { + switch (status) { + case undefined: + return `Could not reach ${baseUrl}` + case 401: + case 403: + return 'Invalid credentials' + case 404: + return 'No Komga server at that URL' + default: + return `Server returned ${status}` + } +} + +/** + * Check a Komga server without persisting anything. Defaults to the saved + * settings so callers that are not editing them can omit both arguments. + */ +export async function checkKomgaConnection( + baseUrl: string = getKomgaBaseURL(), + credentials: { username: string; password: string } = getKomgaCredentials() +): Promise { + try { + const { data, error, response } = await getCurrentUser({ + baseUrl, + auth: (auth) => + auth.scheme === 'basic' + ? `${credentials.username}:${credentials.password}` + : undefined, + }) + + // Komga's SPA answers unknown paths with index.html and a 200, so a base + // URL carrying a stray path looks successful. Require a field only the + // real API returns before calling it connected. + if (!error && data?.email) { + return { ok: true, message: `Connected as ${data.email}` } + } + + if (!error) { + return { ok: false, message: 'That URL is not a Komga API endpoint' } + } + + return { ok: false, message: describeFailure(baseUrl, response?.status) } + } catch { + return { ok: false, message: `Could not reach ${baseUrl}` } + } +} From 743ca1266e428fd66ab26f861e90426ba98eeab7 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:32:51 -0400 Subject: [PATCH 25/31] feat(Komga): library scope, one-shot filter and Nearly Finished, 3.9.0 Adds a library picker and a one-shot toggle to Content Settings, applied to browsing and search alike. The discover endpoints take them as query params while /series/list takes conditions, so both paths are covered. Nearly Finished orders series by how few unread books remain. Komga ignores booksUnreadCount as a sort field, so the ordering happens client side over the in-progress set, which is bounded by what the user is actually reading. --- src/Komga/discover_sections.ts | 5 ++ src/Komga/forms/content_settings_form.ts | 82 ++++++++++++++++++++++-- src/Komga/komga.ts | 68 +++++++++++++++++++- src/Komga/pbconfig.ts | 2 +- src/Komga/utils/config.ts | 25 ++++++++ 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/Komga/discover_sections.ts b/src/Komga/discover_sections.ts index 1ac1c04..93aecbb 100644 --- a/src/Komga/discover_sections.ts +++ b/src/Komga/discover_sections.ts @@ -19,6 +19,11 @@ export const DISCOVER_SECTIONS: DiscoverSectionDefinition[] = [ title: 'Keep Reading', description: 'Series you are part way through', }, + { + id: 'nearlyFinished', + title: 'Nearly Finished', + description: 'Series you have the fewest unread books left in', + }, { id: 'recentlyAdded', title: 'Recently Added Series', diff --git a/src/Komga/forms/content_settings_form.ts b/src/Komga/forms/content_settings_form.ts index bbf3fe7..10f760c 100644 --- a/src/Komga/forms/content_settings_form.ts +++ b/src/Komga/forms/content_settings_form.ts @@ -8,10 +8,14 @@ import { import { getAdultGenres, getHideAdultContent, + getIncludeOneshots, + getSelectedLibraries, setAdultGenres, setHideAdultContent, + setIncludeOneshots, + setSelectedLibraries, } from '../utils/config.js' -import { getGenres } from '../sdk/index.js' +import { getGenres, getLibraries } from '../sdk/index.js' export class ContentSettingsForm extends Form { private hideAdultContent = getHideAdultContent() @@ -19,9 +23,23 @@ export class ContentSettingsForm extends Form { // Populated from the server; getSections is sync so the fetch happens in // formWillAppear and reloads the form when it lands private availableGenres: string[] = [] + private selectedLibraries = getSelectedLibraries() + private availableLibraries: Array<{ id: string; name: string }> = [] + private includeOneshots = getIncludeOneshots() override formWillAppear(): void { - void this.loadGenres() + void this.load() + } + + private async load(): Promise { + await Promise.all([this.loadGenres(), this.loadLibraries()]) + this.reloadForm() + } + + private async loadLibraries(): Promise { + this.availableLibraries = await getLibraries() + .then((r) => (r.data ?? []).map((l) => ({ id: l.id, name: l.name }))) + .catch(() => []) } private async loadGenres(): Promise { @@ -34,8 +52,6 @@ export class ContentSettingsForm extends Form { this.availableGenres = [ ...new Set([...genres.map((g) => g.toLowerCase()), ...this.adultGenres]), ].sort() - - this.reloadForm() } override getSections(): FormSectionElement[] { @@ -75,9 +91,67 @@ export class ContentSettingsForm extends Form { }), ] ), + + Section( + { + id: 'libraries', + header: 'Libraries', + footer: + 'Restrict browsing and search to these libraries. Selecting none includes every library.', + }, + [ + SelectRow('selectedLibraries', { + title: 'Libraries', + value: this.selectedLibraries, + minItemCount: 0, + maxItemCount: this.availableLibraries.length, + layout: 'list', + items: this.availableLibraries.map((library) => ({ + id: library.id, + title: library.name, + })), + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'selectedLibrariesDidChange' + ), + }), + ] + ), + + Section( + { + id: 'oneshots', + footer: + 'One-shots are single-volume works Komga tracks separately from serialised series.', + }, + [ + ToggleRow('includeOneshots', { + title: 'Include One-Shots', + value: this.includeOneshots, + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'includeOneshotsDidChange' + ), + }), + ] + ), ] } + async selectedLibrariesDidChange(newValue: string[]): Promise { + this.selectedLibraries = newValue + setSelectedLibraries(newValue) + Application.invalidateDiscoverSections() + this.reloadForm() + } + + async includeOneshotsDidChange(newValue: boolean): Promise { + this.includeOneshots = newValue + setIncludeOneshots(newValue) + Application.invalidateDiscoverSections() + this.reloadForm() + } + async hideAdultContentDidChange(newValue: boolean): Promise { this.hideAdultContent = newValue setHideAdultContent(newValue) diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index c421b88..61719ba 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -56,12 +56,15 @@ import { import { client } from './sdk/client.gen.js' import { KomgaImageInterceptor } from './interceptors/image_interceptor.js' import { isEqualTo, isFalse, isNotEqualTo, Operator } from './utils.js' +import type { AllOfSeries } from './sdk/types.gen.js' import { getAdultGenres, getHideAdultContent, getKomgaBaseURL, getKomgaCredentials, + getIncludeOneshots, getSectionStyle, + getSelectedLibraries, type SectionStyle, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' @@ -207,6 +210,33 @@ const hiddenSeriesIds = async (): Promise> => { return new Set((data?.content ?? []).map((serie) => serie.id)) } +// Library scope and one-shot inclusion apply to every browse query. The +// discover endpoints take them as query params; /series/list takes conditions. +const scopeQuery = () => { + const libraries = getSelectedLibraries() + return { + ...(libraries.length > 0 ? { library_id: libraries } : {}), + ...(getIncludeOneshots() ? {} : { oneshot: false }), + } +} + +const scopeConditions = () => { + const libraries = getSelectedLibraries() + const conditions: AllOfSeries['allOf'] = [] + + if (libraries.length > 0) { + conditions.push({ + anyOf: libraries.map((id) => ({ libraryId: isEqualTo(id) })), + }) + } + + if (!getIncludeOneshots()) { + conditions.push({ oneShot: isFalse() }) + } + + return conditions +} + const isHiddenSeries = (metadata: { genres?: Array }): boolean => { if (!getHideAdultContent()) { return false @@ -497,7 +527,7 @@ export class KomgaExtension implements ExtensionImpl { const page: number = metadata?.page ?? 0 // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filters: any[] = [...hiddenGenreConditions()] + const filters: any[] = [...hiddenGenreConditions(), ...scopeConditions()] for (const filter of searchQuery.metadata ?? []) { const value = filter.value @@ -810,7 +840,9 @@ export class KomgaExtension implements ExtensionImpl { switch (section.id) { case 'onDeck': { - const { data, error } = await getBooksOnDeck({ query: { page } }) + const { data, error } = await getBooksOnDeck({ + query: { page, ...scopeQuery() }, + }) if (!data) { throw new Error(JSON.stringify(error, undefined, 2)) } @@ -849,6 +881,7 @@ export class KomgaExtension implements ExtensionImpl { { deleted: isFalse() }, { readStatus: isEqualTo('IN_PROGRESS') }, ...hiddenGenreConditions(), + ...scopeConditions(), ], }, }, @@ -864,12 +897,41 @@ export class KomgaExtension implements ExtensionImpl { metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, } } + case 'nearlyFinished': { + // Komga ignores booksUnreadCount as a sort field, so order it here. + // The set is bounded by what the user is actually reading, which keeps + // the unpaged fetch cheap. + const { data, error } = await getSeriesList({ + query: { unpaged: true }, + body: { + condition: { + allOf: [ + { deleted: isFalse() }, + { readStatus: isEqualTo('IN_PROGRESS') }, + ...hiddenGenreConditions(), + ...scopeConditions(), + ], + }, + }, + }) + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const items = (data.content ?? []) + .filter((serie) => serie.booksUnreadCount > 0) + .sort((a, b) => a.booksUnreadCount - b.booksUnreadCount) + .slice(0, PAGE_SIZE) + .map((serie) => this.seriesItem(serie, style)) + + return { items, metadata: undefined } + } case 'recentlyAdded': case 'recentlyUpdated': { const fetch = section.id === 'recentlyAdded' ? getSeriesNew : getSeriesUpdated const { data, error } = await fetch({ - query: { page, deleted: false }, + query: { page, deleted: false, ...scopeQuery() }, }) if (!data) { throw new Error(JSON.stringify(error, undefined, 2)) diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 23d46d6..e181e31 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.8.0', + version: '3.9.0', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index b4ed3c4..55d86a1 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -130,3 +130,28 @@ export function getSectionStyle(sectionId: string): SectionStyle { export function setSectionStyle(sectionId: string, style: SectionStyle) { Application.setState(style, styleKey(sectionId)) } + +const KEY_SELECTED_LIBRARIES = 'selectedLibraries' +const KEY_INCLUDE_ONESHOTS = 'includeOneshots' + +/** Library ids to restrict browsing to. Empty means every library. */ +export function getSelectedLibraries(): string[] { + const stored = Application.getState(KEY_SELECTED_LIBRARIES) + return Array.isArray(stored) ? (stored as string[]) : [] +} + +export function setSelectedLibraries(newValue: string[]) { + Application.setState(newValue, KEY_SELECTED_LIBRARIES) +} + +/** + * Endpoints take `oneshot` as an optional tri-state, where leaving it unset + * returns both. Only pass `false` when one-shots are being excluded. + */ +export function getIncludeOneshots(): boolean { + return (Application.getState(KEY_INCLUDE_ONESHOTS) as boolean) ?? true +} + +export function setIncludeOneshots(newValue: boolean) { + Application.setState(newValue, KEY_INCLUDE_ONESHOTS) +} From 8e56987ca12e13592ec37d79856116fdeb639cee Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:39:02 -0400 Subject: [PATCH 26/31] ci: use npm ci, cache deps, and validate pull requests - npm ci rather than npm install, since a lockfile is committed - cache the npm download directory - set-output was disabled by GitHub in 2023; write to $GITHUB_OUTPUT - run on pull_request as well, with bundling and deployment guarded to pushes so a PR is checked without publishing --- .github/workflows/main.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1d85ea9..a343321 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,6 @@ -on: push +on: + push: + pull_request: name: Bundle and Publish Sources jobs: build: @@ -13,24 +15,31 @@ jobs: uses: actions/setup-node@v5 with: node-version: 22 - + cache: npm + - name: Extract branch name shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" id: extract_branch - name: Checkout existing bundles + if: github.event_name == 'push' uses: actions/checkout@v5 continue-on-error: true with: ref: gh-pages path: bundles - - run: npm install + - run: npm ci - run: npm run check - - run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }} + + # Pull requests stop at the checks above; only pushes publish + - name: Bundle + if: github.event_name == 'push' + run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }} - name: Deploy to GitHub Pages + if: github.event_name == 'push' uses: JamesIves/github-pages-deploy-action@v4.7.3 with: branch: gh-pages From 527c41bc97b893c1168f26bc37ffe5d9edbfd3da Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:44:00 -0400 Subject: [PATCH 27/31] refactor(Komga): split komga.ts into focused modules, 3.9.1 komga.ts had grown to 979 lines, most of it not the extension interface. Moves out discover section building, content rating, query scoping and the shared formatting helpers, leaving 668 lines of interface implementation. Also renames utils.ts to utils/operators.ts, since a file and a directory sharing the name made imports ambiguous. No behaviour change. --- src/Komga/discover.ts | 228 +++++++++++++++ src/Komga/komga.ts | 323 +-------------------- src/Komga/pbconfig.ts | 2 +- src/Komga/utils/content_rating.ts | 39 +++ src/Komga/utils/filters.ts | 77 +++++ src/Komga/utils/formatting.ts | 8 + src/Komga/{utils.ts => utils/operators.ts} | 2 +- 7 files changed, 360 insertions(+), 319 deletions(-) create mode 100644 src/Komga/discover.ts create mode 100644 src/Komga/utils/content_rating.ts create mode 100644 src/Komga/utils/filters.ts create mode 100644 src/Komga/utils/formatting.ts rename src/Komga/{utils.ts => utils/operators.ts} (88%) diff --git a/src/Komga/discover.ts b/src/Komga/discover.ts new file mode 100644 index 0000000..8e2094d --- /dev/null +++ b/src/Komga/discover.ts @@ -0,0 +1,228 @@ +import { + type DiscoverSection, + type DiscoverSectionItem, + type PagedResults, +} from '@paperback/types' +import { + getBooksOnDeck, + getGenres, + getSeries as getSeriesList, + getSeriesNew, + getSeriesUpdated, +} from './sdk/index.js' +import { client } from './sdk/client.gen.js' +import { isEqualTo, isFalse } from './utils/operators.js' +import { + getAdultGenres, + getHideAdultContent, + getSectionStyle, + type SectionStyle, +} from './utils/config.js' +import { parseContentRating } from './utils/content_rating.js' +import { + hiddenGenreConditions, + hiddenSeriesIds, + isHiddenSeries, + scopeConditions, + scopeQuery, +} from './utils/filters.js' +import { capitalize, PAGE_SIZE, parseMangaStatus } from './utils/formatting.js' + +// One builder per source shape, so a section's style only changes how its +// covers are presented rather than duplicating the query +const seriesItem = ( + serie: { + id: string + name: string + booksCount: number + booksReadCount: number + metadata: { + title: string + status: string + publisher: string + summary: string + genres?: Array + ageRating?: number + } + }, + style: SectionStyle +): DiscoverSectionItem => { + const base = { + mangaId: serie.id, + title: serie.metadata.title || serie.name, + imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, + contentRating: parseContentRating(serie.metadata), + } + + if (style === 'hero') { + return { + type: 'featuredCarouselItem', + ...base, + supertitle: [ + parseMangaStatus(serie.metadata.status).toUpperCase(), + serie.metadata.publisher, + ] + .filter(Boolean) + .join(' \u00b7 '), + summary: serie.metadata.summary, + infoItems: [ + { symbol: 'book.fill', text: `${serie.booksCount}` }, + { + symbol: 'checkmark.circle.fill', + text: `${serie.booksReadCount} read`, + }, + ], + } + } + + const subtitle = + serie.booksCount > 0 + ? `${serie.booksReadCount} of ${serie.booksCount} read` + : undefined + + return style === 'large' + ? { type: 'prominentCarouselItem', ...base, subtitle } + : { type: 'simpleCarouselItem', ...base, subtitle: undefined } +} + +export async function discoverSectionItems( + section: DiscoverSection, + metadata: { page: number } | undefined +): Promise> { + const style = getSectionStyle(section.id) + const page = metadata?.page + + switch (section.id) { + case 'onDeck': { + const { data, error } = await getBooksOnDeck({ + query: { page, ...scopeQuery() }, + }) + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const hidden = await hiddenSeriesIds() + const items: DiscoverSectionItem[] = [] + + for (const book of data.content ?? []) { + if (hidden.has(book.seriesId)) { + continue + } + + items.push({ + type: + style === 'large' ? 'prominentCarouselItem' : 'simpleCarouselItem', + mangaId: book.seriesId, + title: book.seriesTitle, + subtitle: book.metadata.title, + imageUrl: `${client.getConfig().baseUrl}/api/v1/books/${book.id}/thumbnail`, + }) + } + + return { + items, + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, + } + } + case 'keepReading': { + const { data, error } = await getSeriesList({ + query: { sort: ['readProgress.readDate,desc'], page }, + body: { + condition: { + allOf: [ + { deleted: isFalse() }, + { readStatus: isEqualTo('IN_PROGRESS') }, + ...hiddenGenreConditions(), + ...scopeConditions(), + ], + }, + }, + }) + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + return { + items: (data.content ?? []).map((serie) => seriesItem(serie, style)), + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, + } + } + case 'nearlyFinished': { + // Komga ignores booksUnreadCount as a sort field, so order it here. + // The set is bounded by what the user is actually reading, which keeps + // the unpaged fetch cheap. + const { data, error } = await getSeriesList({ + query: { unpaged: true }, + body: { + condition: { + allOf: [ + { deleted: isFalse() }, + { readStatus: isEqualTo('IN_PROGRESS') }, + ...hiddenGenreConditions(), + ...scopeConditions(), + ], + }, + }, + }) + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const items = (data.content ?? []) + .filter((serie) => serie.booksUnreadCount > 0) + .sort((a, b) => a.booksUnreadCount - b.booksUnreadCount) + .slice(0, PAGE_SIZE) + .map((serie) => seriesItem(serie, style)) + + return { items, metadata: undefined } + } + case 'recentlyAdded': + case 'recentlyUpdated': { + const fetch = + section.id === 'recentlyAdded' ? getSeriesNew : getSeriesUpdated + const { data, error } = await fetch({ + query: { page, deleted: false, ...scopeQuery() }, + }) + if (!data) { + throw new Error(JSON.stringify(error, undefined, 2)) + } + + const items = (data.content ?? []) + .filter((serie) => !isHiddenSeries(serie.metadata)) + .map((serie) => seriesItem(serie, style)) + + return { + items, + metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, + } + } + case 'genres': { + const genres = await getGenres() + .then((r) => r.data ?? []) + .catch(() => []) + + const hidden = getHideAdultContent() ? getAdultGenres() : [] + + const items: DiscoverSectionItem[] = genres + .filter((genre) => !hidden.includes(genre.toLowerCase())) + .map((genre) => ({ + type: 'genresCarouselItem' as const, + name: capitalize(genre), + searchQuery: { + title: '', + metadata: [ + { + id: 'genre', + value: { ['genre-' + btoa(genre)]: 'included' as const }, + }, + ], + }, + })) + + return { items, metadata: undefined } + } + default: { + throw new Error(`Unknown section ${section.id}`) + } + } +} diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 61719ba..b871e42 100644 --- a/src/Komga/komga.ts +++ b/src/Komga/komga.ts @@ -22,7 +22,6 @@ import { type Chapter, type ChapterDetails, type ChapterReadActionQueueProcessingResult, - ContentRating, type DiscoverSection, type DiscoverSectionItem, DiscoverSectionType, @@ -41,33 +40,30 @@ import { import { getBookPages, getBooks as getBooksList, - getBooksOnDeck, getCollections, getGenres, getLibraries, getMihonReadProgressBySeriesId, getSeriesById as getOneSeries, getSeries as getSeriesList, - getSeriesNew, getSeriesTags, getSeriesUpdated, markBookReadProgress, } from './sdk/index.js' import { client } from './sdk/client.gen.js' import { KomgaImageInterceptor } from './interceptors/image_interceptor.js' -import { isEqualTo, isFalse, isNotEqualTo, Operator } from './utils.js' -import type { AllOfSeries } from './sdk/types.gen.js' +import { Operator } from './utils/operators.js' import { - getAdultGenres, - getHideAdultContent, getKomgaBaseURL, getKomgaCredentials, - getIncludeOneshots, getSectionStyle, - getSelectedLibraries, type SectionStyle, } from './utils/config.js' import { SettingsForm } from './forms/settings_form.js' +import { discoverSectionItems } from './discover.js' +import { capitalize, PAGE_SIZE, parseMangaStatus } from './utils/formatting.js' +import { parseContentRating } from './utils/content_rating.js' +import { hiddenGenreConditions, scopeConditions } from './utils/filters.js' import { DISCOVER_SECTIONS } from './discover_sections.js' import { parseChapterTitle } from './utils/titles.js' import { ProgressManagementForm } from './forms/progress_management_form.js' @@ -80,52 +76,7 @@ const SUPPORTED_IMAGE_TYPES = [ 'image/webp', 'application/pdf', ] -// Number of items requested for paged requests -const PAGE_SIZE = 40 -export const parseMangaStatus = (komgaStatus: string): string => { - return komgaStatus.toLowerCase() -} -export const capitalize = (tag: string): string => { - return tag.replace(/^\w/, (c) => c.toUpperCase()) -} - -// Komga libraries rarely set ageRating, so genres are the usable signal. These -// are matched case-insensitively against SeriesMetadataDto.genres. -const ADULT_GENRES = ['adult', 'hentai', 'smut', 'erotica', 'pornographic'] -const MATURE_GENRES = ['mature', 'ecchi'] - -// ageRating is a minimum age when set; otherwise fall back to genres -export const parseContentRating = (metadata: { - ageRating?: number - genres?: Array -}): ContentRating => { - // Komga sends `ageRating: null` on the wire even though the generated type - // declares it optional, so check the runtime type rather than for undefined - const { ageRating } = metadata - if (typeof ageRating === 'number') { - if (ageRating >= 18) { - return ContentRating.ADULT - } - if (ageRating >= 16) { - return ContentRating.MATURE - } - return ContentRating.EVERYONE - } - - const genres = (metadata.genres ?? []).map((genre) => genre.toLowerCase()) - if (genres.some((genre) => ADULT_GENRES.includes(genre))) { - return ContentRating.ADULT - } - if (genres.some((genre) => MATURE_GENRES.includes(genre))) { - return ContentRating.MATURE - } - return ContentRating.EVERYONE -} -// Komga silently ignores an unrecognised sort field rather than erroring, so -// every entry here was checked against a live server by confirming asc and desc -// actually differ. `titleSort`, `releaseDate` and `folderName` are all ignored; -// the working title field is `metadata.titleSort`. // A series' lastModified can trail its books' created time by a few seconds // when both are written during one library scan, so look slightly further back // than the app's last check to avoid skipping a series that did gain chapters. @@ -181,72 +132,6 @@ const pickShareUrl = ( return links[0]?.url } -// `/series/list` takes a search condition, so hidden genres are excluded by the -// server. `/series/new` and `/series/updated` take no condition, so those get -// filtered here instead. -const hiddenGenreConditions = () => { - if (!getHideAdultContent()) { - return [] - } - return getAdultGenres().map((genre) => ({ genre: isNotEqualTo(genre) })) -} - -// On Deck returns books, and Komga puts genres only on series. Rather than a -// lookup per book, fetch the hidden series once and filter by membership. -const hiddenSeriesIds = async (): Promise> => { - if (!getHideAdultContent()) { - return new Set() - } - - const { data } = await getSeriesList({ - query: { unpaged: true }, - body: { - condition: { - anyOf: getAdultGenres().map((genre) => ({ genre: isEqualTo(genre) })), - }, - }, - }).catch(() => ({ data: undefined })) - - return new Set((data?.content ?? []).map((serie) => serie.id)) -} - -// Library scope and one-shot inclusion apply to every browse query. The -// discover endpoints take them as query params; /series/list takes conditions. -const scopeQuery = () => { - const libraries = getSelectedLibraries() - return { - ...(libraries.length > 0 ? { library_id: libraries } : {}), - ...(getIncludeOneshots() ? {} : { oneshot: false }), - } -} - -const scopeConditions = () => { - const libraries = getSelectedLibraries() - const conditions: AllOfSeries['allOf'] = [] - - if (libraries.length > 0) { - conditions.push({ - anyOf: libraries.map((id) => ({ libraryId: isEqualTo(id) })), - }) - } - - if (!getIncludeOneshots()) { - conditions.push({ oneShot: isFalse() }) - } - - return conditions -} - -const isHiddenSeries = (metadata: { genres?: Array }): boolean => { - if (!getHideAdultContent()) { - return false - } - const hidden = getAdultGenres() - return (metadata.genres ?? []).some((genre) => - hidden.includes(genre.toLowerCase()) - ) -} - export class KomgaExtension implements ExtensionImpl { async getAdvancedSearchForm( query: SearchQuery @@ -774,206 +659,10 @@ export class KomgaExtension implements ExtensionImpl { return sections } - // One builder per source shape, so a section's style only changes how its - // covers are presented rather than duplicating the query - private seriesItem( - serie: { - id: string - name: string - booksCount: number - booksReadCount: number - metadata: { - title: string - status: string - publisher: string - summary: string - genres?: Array - ageRating?: number - } - }, - style: SectionStyle - ): DiscoverSectionItem { - const base = { - mangaId: serie.id, - title: serie.metadata.title || serie.name, - imageUrl: `${client.getConfig().baseUrl}/api/v1/series/${serie.id}/thumbnail`, - contentRating: parseContentRating(serie.metadata), - } - - if (style === 'hero') { - return { - type: 'featuredCarouselItem', - ...base, - supertitle: [ - parseMangaStatus(serie.metadata.status).toUpperCase(), - serie.metadata.publisher, - ] - .filter(Boolean) - .join(' \u00b7 '), - summary: serie.metadata.summary, - infoItems: [ - { symbol: 'book.fill', text: `${serie.booksCount}` }, - { - symbol: 'checkmark.circle.fill', - text: `${serie.booksReadCount} read`, - }, - ], - } - } - - const subtitle = - serie.booksCount > 0 - ? `${serie.booksReadCount} of ${serie.booksCount} read` - : undefined - - return style === 'large' - ? { type: 'prominentCarouselItem', ...base, subtitle } - : { type: 'simpleCarouselItem', ...base, subtitle: undefined } - } - async getDiscoverSectionItems( section: DiscoverSection, metadata: { page: number } | undefined ): Promise> { - const style = getSectionStyle(section.id) - const page = metadata?.page - - switch (section.id) { - case 'onDeck': { - const { data, error } = await getBooksOnDeck({ - query: { page, ...scopeQuery() }, - }) - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const hidden = await hiddenSeriesIds() - const items: DiscoverSectionItem[] = [] - - for (const book of data.content ?? []) { - if (hidden.has(book.seriesId)) { - continue - } - - items.push({ - type: - style === 'large' - ? 'prominentCarouselItem' - : 'simpleCarouselItem', - mangaId: book.seriesId, - title: book.seriesTitle, - subtitle: book.metadata.title, - imageUrl: `${client.getConfig().baseUrl}/api/v1/books/${book.id}/thumbnail`, - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, - } - } - case 'keepReading': { - const { data, error } = await getSeriesList({ - query: { sort: ['readProgress.readDate,desc'], page }, - body: { - condition: { - allOf: [ - { deleted: isFalse() }, - { readStatus: isEqualTo('IN_PROGRESS') }, - ...hiddenGenreConditions(), - ...scopeConditions(), - ], - }, - }, - }) - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - return { - items: (data.content ?? []).map((serie) => - this.seriesItem(serie, style) - ), - metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, - } - } - case 'nearlyFinished': { - // Komga ignores booksUnreadCount as a sort field, so order it here. - // The set is bounded by what the user is actually reading, which keeps - // the unpaged fetch cheap. - const { data, error } = await getSeriesList({ - query: { unpaged: true }, - body: { - condition: { - allOf: [ - { deleted: isFalse() }, - { readStatus: isEqualTo('IN_PROGRESS') }, - ...hiddenGenreConditions(), - ...scopeConditions(), - ], - }, - }, - }) - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items = (data.content ?? []) - .filter((serie) => serie.booksUnreadCount > 0) - .sort((a, b) => a.booksUnreadCount - b.booksUnreadCount) - .slice(0, PAGE_SIZE) - .map((serie) => this.seriesItem(serie, style)) - - return { items, metadata: undefined } - } - case 'recentlyAdded': - case 'recentlyUpdated': { - const fetch = - section.id === 'recentlyAdded' ? getSeriesNew : getSeriesUpdated - const { data, error } = await fetch({ - query: { page, deleted: false, ...scopeQuery() }, - }) - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items = (data.content ?? []) - .filter((serie) => !isHiddenSeries(serie.metadata)) - .map((serie) => this.seriesItem(serie, style)) - - return { - items, - metadata: data.last ? undefined : { page: (page ?? 0) + 1 }, - } - } - case 'genres': { - const genres = await getGenres() - .then((r) => r.data ?? []) - .catch(() => []) - - const hidden = getHideAdultContent() ? getAdultGenres() : [] - - const items: DiscoverSectionItem[] = genres - .filter((genre) => !hidden.includes(genre.toLowerCase())) - .map((genre) => ({ - type: 'genresCarouselItem' as const, - name: capitalize(genre), - searchQuery: { - title: '', - metadata: [ - { - id: 'genre', - value: { ['genre-' + btoa(genre)]: 'included' as const }, - }, - ], - }, - })) - - return { items, metadata: undefined } - } - default: { - throw new Error(`Unknown section ${section.id}`) - } - } + return discoverSectionItems(section, metadata) } } diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index e181e31..74d8b85 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.9.0', + version: '3.9.1', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/content_rating.ts b/src/Komga/utils/content_rating.ts new file mode 100644 index 0000000..b80a9fc --- /dev/null +++ b/src/Komga/utils/content_rating.ts @@ -0,0 +1,39 @@ +import { ContentRating } from '@paperback/types' + +// Komga libraries rarely set ageRating, so genres are the usable signal. These +// are matched case-insensitively against SeriesMetadataDto.genres. +const ADULT_GENRES = ['adult', 'hentai', 'smut', 'erotica', 'pornographic'] +const MATURE_GENRES = ['mature', 'ecchi'] + +// ageRating is a minimum age when set; otherwise fall back to genres +export const parseContentRating = (metadata: { + ageRating?: number + genres?: Array +}): ContentRating => { + // Komga sends `ageRating: null` on the wire even though the generated type + // declares it optional, so check the runtime type rather than for undefined + const { ageRating } = metadata + if (typeof ageRating === 'number') { + if (ageRating >= 18) { + return ContentRating.ADULT + } + if (ageRating >= 16) { + return ContentRating.MATURE + } + return ContentRating.EVERYONE + } + + const genres = (metadata.genres ?? []).map((genre) => genre.toLowerCase()) + if (genres.some((genre) => ADULT_GENRES.includes(genre))) { + return ContentRating.ADULT + } + if (genres.some((genre) => MATURE_GENRES.includes(genre))) { + return ContentRating.MATURE + } + return ContentRating.EVERYONE +} + +// Komga silently ignores an unrecognised sort field rather than erroring, so +// every entry here was checked against a live server by confirming asc and desc +// actually differ. `titleSort`, `releaseDate` and `folderName` are all ignored; +// the working title field is `metadata.titleSort`. diff --git a/src/Komga/utils/filters.ts b/src/Komga/utils/filters.ts new file mode 100644 index 0000000..32d31d4 --- /dev/null +++ b/src/Komga/utils/filters.ts @@ -0,0 +1,77 @@ +import { getSeries as getSeriesList } from '../sdk/index.js' +import type { AllOfSeries } from '../sdk/types.gen.js' +import { isEqualTo, isFalse, isNotEqualTo } from './operators.js' +import { + getAdultGenres, + getHideAdultContent, + getIncludeOneshots, + getSelectedLibraries, +} from './config.js' + +// `/series/list` takes a search condition, so hidden genres are excluded by the +// server. `/series/new` and `/series/updated` take no condition, so those get +// filtered here instead. +export const hiddenGenreConditions = () => { + if (!getHideAdultContent()) { + return [] + } + return getAdultGenres().map((genre) => ({ genre: isNotEqualTo(genre) })) +} + +// On Deck returns books, and Komga puts genres only on series. Rather than a +// lookup per book, fetch the hidden series once and filter by membership. +export const hiddenSeriesIds = async (): Promise> => { + if (!getHideAdultContent()) { + return new Set() + } + + const { data } = await getSeriesList({ + query: { unpaged: true }, + body: { + condition: { + anyOf: getAdultGenres().map((genre) => ({ genre: isEqualTo(genre) })), + }, + }, + }).catch(() => ({ data: undefined })) + + return new Set((data?.content ?? []).map((serie) => serie.id)) +} + +// Library scope and one-shot inclusion apply to every browse query. The +// discover endpoints take them as query params; /series/list takes conditions. +export const scopeQuery = () => { + const libraries = getSelectedLibraries() + return { + ...(libraries.length > 0 ? { library_id: libraries } : {}), + ...(getIncludeOneshots() ? {} : { oneshot: false }), + } +} + +export const scopeConditions = () => { + const libraries = getSelectedLibraries() + const conditions: AllOfSeries['allOf'] = [] + + if (libraries.length > 0) { + conditions.push({ + anyOf: libraries.map((id) => ({ libraryId: isEqualTo(id) })), + }) + } + + if (!getIncludeOneshots()) { + conditions.push({ oneShot: isFalse() }) + } + + return conditions +} + +export const isHiddenSeries = (metadata: { + genres?: Array +}): boolean => { + if (!getHideAdultContent()) { + return false + } + const hidden = getAdultGenres() + return (metadata.genres ?? []).some((genre) => + hidden.includes(genre.toLowerCase()) + ) +} diff --git a/src/Komga/utils/formatting.ts b/src/Komga/utils/formatting.ts new file mode 100644 index 0000000..d255f07 --- /dev/null +++ b/src/Komga/utils/formatting.ts @@ -0,0 +1,8 @@ +// Number of items requested for paged requests +export const PAGE_SIZE = 40 + +export const parseMangaStatus = (komgaStatus: string): string => + komgaStatus.toLowerCase() + +export const capitalize = (tag: string): string => + tag.replace(/^\w/, (c) => c.toUpperCase()) diff --git a/src/Komga/utils.ts b/src/Komga/utils/operators.ts similarity index 88% rename from src/Komga/utils.ts rename to src/Komga/utils/operators.ts index 53a37ca..f255693 100644 --- a/src/Komga/utils.ts +++ b/src/Komga/utils/operators.ts @@ -1,4 +1,4 @@ -import type { Is, IsFalse, IsNot, IsTrue } from './sdk/types.gen.js' +import type { Is, IsFalse, IsNot, IsTrue } from '../sdk/types.gen.js' export function Operator( o: { operator: Uncapitalize } & V From ea5fc02acac9d8fd0929d173dd07fb4be29f7c3f Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:51:18 -0400 Subject: [PATCH 28/31] chore: fix root config issues, 3.9.2 - types pointed at index.d.js rather than index.d.ts - fast-xml-parser was declared but imported nowhere - src/tests/suite.ts imports chai directly, but only @types/chai was declared; it was resolving by hoisting from @paperback/toolchain - lint and format only covered src, so the root .mjs config files were never checked, and openapi-ts.config.mjs had drifted out of style - dropped a commented-out import from the eslint config --- eslint.config.mjs | 1 - openapi-ts.config.mjs | 6 +-- package-lock.json | 100 +----------------------------------------- package.json | 10 ++--- src/Komga/pbconfig.ts | 2 +- 5 files changed, 10 insertions(+), 109 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5859b8a..798a6d9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,5 @@ import js from '@eslint/js' import tseslint from 'typescript-eslint' -// import json from "@eslint/json"; import { defineConfig } from 'eslint/config' import eslintConfigPrettier from 'eslint-config-prettier/flat' diff --git a/openapi-ts.config.mjs b/openapi-ts.config.mjs index fada154..3c3c13d 100644 --- a/openapi-ts.config.mjs +++ b/openapi-ts.config.mjs @@ -1,5 +1,5 @@ import path from 'node:path' -import {paperbackClientPlugin} from './src/paperback-openapi-client/plugin.js' +import { paperbackClientPlugin } from './src/paperback-openapi-client/plugin.js' /** @type {import('@hey-api/openapi-ts').UserConfig} */ export default { @@ -14,6 +14,6 @@ export default { parser: {}, plugins: [ ...['@hey-api/typescript', '@hey-api/sdk'], - paperbackClientPlugin() - ] + paperbackClientPlugin(), + ], } diff --git a/package-lock.json b/package-lock.json index 7027a90..dc3dd1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/chai": "^5.2.3", + "chai": "^6.2.0", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", - "fast-xml-parser": "^5.8.0", "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" @@ -448,19 +448,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodable/entities": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", - "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1997,46 +1984,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", - "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.2.0", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.3.0", - "xml-naming": "^0.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2674,22 +2621,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3053,19 +2984,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3257,22 +3175,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 866aa8b..6be42da 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "repositoryName": "Default Extensions", "description": "Default extensions for Paperback 0.8", "main": "./lib/index.js", - "types": "./lib/index.d.js", + "types": "./lib/index.d.ts", "type": "module", "engines": { "node": ">=18.0.0" }, "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src", - "format": "prettier --write src", - "format:check": "prettier --check src", + "lint": "eslint src *.mjs", + "format": "prettier --write src *.mjs", + "format:check": "prettier --check src *.mjs", "check": "npm run typecheck && npm run lint && npm run format:check", "test": "npx paperback-cli test", "bundle": "npx paperback-cli bundle", @@ -33,9 +33,9 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/chai": "^5.2.3", + "chai": "^6.2.0", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", - "fast-xml-parser": "^5.8.0", "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 74d8b85..6147c74 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.9.1', + version: '3.9.2', name: 'Komga', icon: 'icon.png', developers: [ From cf2c3cf26d37911d2c260fecc43ba74e63fa41b4 Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:01:22 -0400 Subject: [PATCH 29/31] fix(Komga): restore chapter names in the progress form, 3.9.3 getChapters sets an empty title for a chapter with no name of its own, which is the common case. The progress form fell back with ??, so it only caught null and undefined and showed blank rows for 92% of chapters. --- src/Komga/forms/progress_management_form.ts | 4 ++-- src/Komga/pbconfig.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Komga/forms/progress_management_form.ts b/src/Komga/forms/progress_management_form.ts index 6211a1c..6b8256a 100644 --- a/src/Komga/forms/progress_management_form.ts +++ b/src/Komga/forms/progress_management_form.ts @@ -53,7 +53,7 @@ export class ProgressManagementForm extends Form { } private get currentChapterTitle(): string { - return this.selectedChapter?.title ?? 'No chapters read' + return this.selectedChapter?.title || 'No chapters read' } override getSections(): FormSectionElement[] { @@ -80,7 +80,7 @@ export class ProgressManagementForm extends Form { { id: 'unread', title: 'No chapters read' }, ...this.sortedChapters.map((chapter) => ({ id: chapter.chapterId, - title: chapter.title ?? `Chapter ${chapter.chapNum}`, + title: chapter.title || `Chapter ${chapter.chapNum}`, })), ], onValueChange: Application.Selector( diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index 6147c74..be20030 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.9.2', + version: '3.9.3', name: 'Komga', icon: 'icon.png', developers: [ From 629574d48bb4b9c5e486de853d24356c146d2eba Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:30:40 -0400 Subject: [PATCH 30/31] ci: only run on push Reverts the pull_request trigger; keeps npm ci, dependency caching and the $GITHUB_OUTPUT change. --- .github/workflows/main.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a343321..85c4802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,4 @@ -on: - push: - pull_request: +on: push name: Bundle and Publish Sources jobs: build: @@ -23,7 +21,6 @@ jobs: id: extract_branch - name: Checkout existing bundles - if: github.event_name == 'push' uses: actions/checkout@v5 continue-on-error: true with: @@ -32,14 +29,9 @@ jobs: - run: npm ci - run: npm run check - - # Pull requests stop at the checks above; only pushes publish - - name: Bundle - if: github.event_name == 'push' - run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }} + - run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }} - name: Deploy to GitHub Pages - if: github.event_name == 'push' uses: JamesIves/github-pages-deploy-action@v4.7.3 with: branch: gh-pages From 6d14ed86b4c87330093ded01bafe3518d9c782ae Mon Sep 17 00:00:00 2001 From: Dixon D'Cunha <1643792+Exikle@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:46:41 -0400 Subject: [PATCH 31/31] refactor(Komga): one source for the adult genre list, 3.9.4 config.ts repeated the same seven genres that content_rating.ts already defines, so the two could drift. The default hide list now derives from them. --- src/Komga/pbconfig.ts | 2 +- src/Komga/utils/config.ts | 12 +++--------- src/Komga/utils/content_rating.ts | 13 ++++++++++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Komga/pbconfig.ts b/src/Komga/pbconfig.ts index be20030..ba02088 100644 --- a/src/Komga/pbconfig.ts +++ b/src/Komga/pbconfig.ts @@ -5,7 +5,7 @@ import { } from '@paperback/types' export default { - version: '3.9.3', + version: '3.9.4', name: 'Komga', icon: 'icon.png', developers: [ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 55d86a1..39954f7 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -1,3 +1,5 @@ +import { ADULT_GENRES, MATURE_GENRES } from './content_rating.js' + const KEY_KOMGA_BASE_URL = 'serverURL' const KEY_KOMGA_USERNAME = 'serverUsername' const KEY_KOMGA_PASSWORD = 'serverPassword' @@ -9,15 +11,7 @@ const DEFAULT_KOMGA_USERNAME = 'demo@komga.org' const DEFAULT_KOMGA_PASSWORD = 'komga-demo' const DEFAULT_HIDE_ADULT_CONTENT = false // Matched case-insensitively against a series' genres -const DEFAULT_ADULT_GENRES = [ - 'adult', - 'hentai', - 'smut', - 'erotica', - 'pornographic', - 'mature', - 'ecchi', -] +const DEFAULT_ADULT_GENRES = [...ADULT_GENRES, ...MATURE_GENRES] function getStateOrDefault(key: string, def: T): T { return (Application.getState(key) as T) ?? def diff --git a/src/Komga/utils/content_rating.ts b/src/Komga/utils/content_rating.ts index b80a9fc..4503ab3 100644 --- a/src/Komga/utils/content_rating.ts +++ b/src/Komga/utils/content_rating.ts @@ -1,9 +1,16 @@ import { ContentRating } from '@paperback/types' // Komga libraries rarely set ageRating, so genres are the usable signal. These -// are matched case-insensitively against SeriesMetadataDto.genres. -const ADULT_GENRES = ['adult', 'hentai', 'smut', 'erotica', 'pornographic'] -const MATURE_GENRES = ['mature', 'ecchi'] +// are matched case-insensitively against SeriesMetadataDto.genres, and are the +// single source for both the rating below and the default hide list in config. +export const ADULT_GENRES = [ + 'adult', + 'hentai', + 'smut', + 'erotica', + 'pornographic', +] +export const MATURE_GENRES = ['mature', 'ecchi'] // ageRating is a minimum age when set; otherwise fall back to genres export const parseContentRating = (metadata: {