diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4d0a9f5..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/.DS_Store b/.github/.DS_Store deleted file mode 100644 index 5dc8e2d..0000000 Binary files a/.github/.DS_Store and /dev/null differ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 56f2019..85c4802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,10 +13,11 @@ 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 @@ -26,7 +27,8 @@ jobs: 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 }} - name: Deploy to GitHub Pages 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/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 b56cb18..6be42da 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,17 @@ "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 *.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", "serve": "npx paperback-cli serve", @@ -28,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/.DS_Store b/src/.DS_Store deleted file mode 100644 index a95903e..0000000 Binary files a/src/.DS_Store and /dev/null differ 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/discover_sections.ts b/src/Komga/discover_sections.ts new file mode 100644 index 0000000..93aecbb --- /dev/null +++ b/src/Komga/discover_sections.ts @@ -0,0 +1,43 @@ +// 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: 'nearlyFinished', + title: 'Nearly Finished', + description: 'Series you have the fewest unread books left in', + }, + { + 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/content_settings_form.ts b/src/Komga/forms/content_settings_form.ts new file mode 100644 index 0000000..10f760c --- /dev/null +++ b/src/Komga/forms/content_settings_form.ts @@ -0,0 +1,169 @@ +import { + Form, + Section, + SelectRow, + ToggleRow, + type FormSectionElement, +} from '@paperback/types' +import { + getAdultGenres, + getHideAdultContent, + getIncludeOneshots, + getSelectedLibraries, + setAdultGenres, + setHideAdultContent, + setIncludeOneshots, + setSelectedLibraries, +} from '../utils/config.js' +import { getGenres, getLibraries } 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[] = [] + private selectedLibraries = getSelectedLibraries() + private availableLibraries: Array<{ id: string; name: string }> = [] + private includeOneshots = getIncludeOneshots() + + override formWillAppear(): void { + 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 { + 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() + } + + 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.', + }, + [ + ToggleRow('hideAdultContent', { + title: 'Hide Adult Content', + value: this.hideAdultContent, + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'hideAdultContentDidChange' + ), + }), + SelectRow('adultGenres', { + title: 'Genres To Hide', + // 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: btoa(genre), + title: genre, + })), + onValueChange: Application.Selector( + this as ContentSettingsForm, + 'adultGenresDidChange' + ), + }), + ] + ), + + 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) + Application.invalidateDiscoverSections() + this.reloadForm() + } + + async adultGenresDidChange(newValue: string[]): Promise { + const genres = newValue.map(atob) + this.adultGenres = genres + setAdultGenres(genres) + Application.invalidateDiscoverSections() + this.reloadForm() + } +} diff --git a/src/Komga/forms/homepage_settings_form.ts b/src/Komga/forms/homepage_settings_form.ts index b1cbe0b..b1d2c39 100644 --- a/src/Komga/forms/homepage_settings_form.ts +++ b/src/Komga/forms/homepage_settings_form.ts @@ -1,52 +1,71 @@ import { + closureSelector, Form, Section, + SelectRow, ToggleRow, type FormSectionElement, } from '@paperback/types' import { - getShowContinueReading, - getShowOnDeck, - setShowContinueReading, - setShowOnDeck, + 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 Hompage 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' - ), - }), - ] - ) - } - - async showOnDeckDidChange(newValue: boolean): Promise { - setShowOnDeck(newValue) - Application.invalidateDiscoverSections() - } - - async showContinueReadingDidChange(newValue: boolean): Promise { - setShowContinueReading(newValue) + private applyStyle(sectionId: string, style: SectionStyle): void { + setSectionStyle(sectionId, style) Application.invalidateDiscoverSections() } } 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/forms/server_settings_form.ts b/src/Komga/forms/server_settings_form.ts index 6d4346c..b253aa9 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, @@ -10,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() @@ -62,6 +65,7 @@ export class ServerSettingsForm extends Form { InputRow('password', { title: 'Password', value: this.credentials.password, + isSecureEntry: true, onValueChange: Application.Selector( this as ServerSettingsForm, 'passwordDidChange' @@ -71,40 +75,63 @@ 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() + + const { ok, message } = await checkKomgaConnection( + this.baseUrl, + this.credentials + ) + this.connectionStatus = { text: message, style: ok ? 'success' : 'error' } + this.reloadForm() + } + override getSections(): FormSectionElement[] { - return [this.baseUrlSection(), this.credentialsSection()] + return [ + this.baseUrlSection(), + this.credentialsSection(), + this.connectionSection(), + ] } // Validate the credentials 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) } - switch (response.status) { - case 401: { - throw new Error('Error 401 Unauthorized: Invalid credentials') - } - default: { - throw new Error( - `Error ${response.status}: ${error.violations.map((x) => x.message).join('\n')}` - ) - } - } + 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 d2917d7..da2e3af 100644 --- a/src/Komga/forms/settings_form.ts +++ b/src/Komga/forms/settings_form.ts @@ -1,22 +1,59 @@ -import { Form, NavigationRow, Section, type FormSectionElement } from '@paperback/types' +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(), - }), + }), ]), - + Section('homepageSettings', [ - NavigationRow('authentication', { + NavigationRow('homepageSettings', { title: 'Homepage Settings', form: new HomepageSettingsForm(), - }), + }), + ]), + + Section('contentSettings', [ + NavigationRow('contentSettings', { + title: 'Content Settings', + form: new ContentSettingsForm(), + }), ]), ] } 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 } diff --git a/src/Komga/komga.ts b/src/Komga/komga.ts index 51d33ea..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, @@ -32,35 +31,41 @@ import { type PagedResults, type SearchQuery, type SearchResultItem, + type SortingOption, type SourceManga, + type UpdateManager, type TagSection, type TrackedMangaChapterReadAction, } from '@paperback/types' 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, Operator } from './utils.js' +import { Operator } from './utils/operators.js' import { getKomgaBaseURL, getKomgaCredentials, - getShowContinueReading, - getShowOnDeck, + getSectionStyle, + 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' import type KomgaConfig from './pbconfig.js' @@ -71,13 +76,60 @@ 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() + +// 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 STYLE_TO_SECTION_TYPE: Record< + Exclude, + DiscoverSectionType +> = { + simple: DiscoverSectionType.simpleCarousel, + large: DiscoverSectionType.prominentCarousel, + hero: DiscoverSectionType.featured, } -export const capitalize = (tag: string): string => { - return tag.replace(/^\w/, (c) => c.toUpperCase()) + +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 = [ + '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 } export class KomgaExtension implements ExtensionImpl { @@ -228,15 +280,25 @@ 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), status: parseMangaStatus(metadata.status), artist: artists.join(', '), 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), }, }, } @@ -251,26 +313,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', @@ -307,11 +366,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: {}, } @@ -337,20 +395,24 @@ 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filters: any[] = [] + const filters: any[] = [...hiddenGenreConditions(), ...scopeConditions()] for (const filter of searchQuery.metadata ?? []) { const value = filter.value @@ -363,25 +425,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 } }) } } @@ -392,7 +453,7 @@ export class KomgaExtension implements ExtensionImpl { query: { page, size: PAGE_SIZE, - sort: [orderResultsAlphabetically ? 'titleSort' : 'lastModified,desc'], + sort: [sort], }, body: { fullTextSearch: searchQuery.title, @@ -420,6 +481,7 @@ export class KomgaExtension implements ExtensionImpl { title: serie.metadata.title, mangaId: serie.id, subtitle: undefined, + contentRating: parseContentRating(serie.metadata), }) } @@ -458,22 +520,86 @@ 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), + // When the book landed in the library, as opposed to when it published + creationDate: new Date(book.created), sortingIndex: book.metadata.numberSort, sourceManga: sourceManga, - version: languageCode, }) } 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 }, // @@ -515,37 +641,21 @@ export class KomgaExtension implements ExtensionImpl { async getDiscoverSections(): Promise { const sections: DiscoverSection[] = [] - const showOnDeck = getShowOnDeck() - const showContinueReading = getShowContinueReading() - - if (showOnDeck) { - sections.push({ - id: 'showOnDeck', - title: 'On Deck', - type: DiscoverSectionType.simpleCarousel, - }) - } + for (const definition of DISCOVER_SECTIONS) { + const style = getSectionStyle(definition.id) + if (style === 'hidden') { + continue + } - if (showContinueReading) { sections.push({ - id: 'continueReading', - title: 'Continue Reading', - type: DiscoverSectionType.simpleCarousel, + id: definition.id, + title: definition.title, + type: definition.fixedStyle + ? DiscoverSectionType.genres + : STYLE_TO_SECTION_TYPE[style], }) } - sections.push({ - id: 'recentlyAdded', - title: 'Recently Added', - type: DiscoverSectionType.simpleCarousel, - }) - - sections.push({ - id: 'recentlyUpdated', - title: 'Recently Updated', - type: DiscoverSectionType.simpleCarousel, - }) - return sections } @@ -553,124 +663,6 @@ export class KomgaExtension implements ExtensionImpl { section: DiscoverSection, metadata: { page: number } | undefined ): Promise> { - switch (section.id) { - case 'showOnDeck': { - const { data, error } = await getBooksOnDeck({ - query: { page: metadata?.page }, - }) - - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - 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, - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - case 'continueReading': { - const { data, error } = await getSeriesList({ - query: { sort: ['readProgress.readDate,desc'], page: metadata?.page }, - body: { - condition: { - deleted: isFalse(), - readStatus: isEqualTo('IN_PROGRESS'), - }, - }, - }) - - if (!data) { - throw new Error(JSON.stringify(error, undefined, 2)) - } - - const items: DiscoverSectionItem[] = [] - for (const serie of data.content ?? []) { - 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, - }) - } - - 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 ?? []) { - 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, - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - 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 ?? []) { - 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, - }) - } - - return { - items, - metadata: data.last ? undefined : { page: (metadata?.page ?? 0) + 1 }, - } - } - default: { - throw new Error('Unknown section') - } - } + return discoverSectionItems(section, metadata) } } 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/pbconfig.ts b/src/Komga/pbconfig.ts index 8226e19..ba02088 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.9.4', 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, diff --git a/src/Komga/sdk/client/.DS_Store b/src/Komga/sdk/client/.DS_Store deleted file mode 100644 index 23a9753..0000000 Binary files a/src/Komga/sdk/client/.DS_Store and /dev/null differ diff --git a/src/Komga/utils/config.ts b/src/Komga/utils/config.ts index 409a7fe..39954f7 100644 --- a/src/Komga/utils/config.ts +++ b/src/Komga/utils/config.ts @@ -1,17 +1,20 @@ +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' -const KEY_SHOW_ON_DECK = 'showOnDeck' -const KEY_SHOW_CONTINUE_READING = 'showContinueReading' +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_HIDE_ADULT_CONTENT = false +// Matched case-insensitively against a series' genres +const DEFAULT_ADULT_GENRES = [...ADULT_GENRES, ...MATURE_GENRES] 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 +27,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,19 +41,111 @@ export function setKomgaCredentials(username: string, password: string) { Application.setSecureState(password, KEY_KOMGA_PASSWORD) } +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) +} -export function getShowOnDeck() { - return getStateOrDefault(KEY_SHOW_ON_DECK, DEFAULT_SHOW_ON_DECK) +// 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 setShowOnDeck(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_ON_DECK) +export function setAdultGenres(newValue: string[]) { + Application.setState(newValue, KEY_ADULT_GENRES) } -export function getShowContinueReading() { - return getStateOrDefault(KEY_SHOW_CONTINUE_READING, DEFAULT_SHOW_CONTINUE_READING) +// 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}` + +// 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 setShowContinueReading(newValue: boolean) { - Application.setState(newValue, KEY_SHOW_CONTINUE_READING) -} \ No newline at end of file +const LEGACY_STYLE_SECTION: Record = { + showFeatured: 'hero', + showProminent: 'large', +} + +const LEGACY_STYLE_OWNER: Record = { + showFeatured: 'recentlyAdded', + showProminent: 'keepReading', +} + +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 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) +} 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}` } + } +} diff --git a/src/Komga/utils/content_rating.ts b/src/Komga/utils/content_rating.ts new file mode 100644 index 0000000..4503ab3 --- /dev/null +++ b/src/Komga/utils/content_rating.ts @@ -0,0 +1,46 @@ +import { ContentRating } from '@paperback/types' + +// Komga libraries rarely set ageRating, so genres are the usable signal. These +// 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: { + 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 87% rename from src/Komga/utils.ts rename to src/Komga/utils/operators.ts index c15b7a5..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 @@ -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/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 } +} 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/.DS_Store b/src/paperback-openapi-client/.DS_Store deleted file mode 100644 index 23a9753..0000000 Binary files a/src/paperback-openapi-client/.DS_Store and /dev/null differ 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() +}