commit 6514dc165b6a8a924351c92f0dc21be8a7bbb3e0 Author: oxypomme Date: Tue Dec 30 19:52:07 2025 +0100 feat: initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6aa4d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.zed/ +.prettierc.yml +.oxlintrc.json +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..e6080c2 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,143 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["oxc", "unicorn", "typescript", "import", "node", "promise"], + "categories": { + "correctness": "error", + "suspicious": "error", + "pedantic": "warn", + "perf": "warn" + }, + "rules": { + // It's a TypeScript project, it should use ECMA imports + "import/no-amd": "error", + "import/no-commonjs": "error", + + // Restrict dangerous practices + "eslint/eqeqeq": ["error", "always", { "null": "ignore" }], + "default-case": "error", + "no-bitwise": "error", + "no-console": "off", + "no-empty": "error", + "no-empty-function": "error", + "no-iterator": "error", + "no-plusplus": "error", + "no-proto": "error", + "no-regex-spaces": "error", + "no-restricted-globals": "error", + "no-unused-expressions": "error", + "no-var": "error", + "no-void": "error", + "no-new-require": "error", + "no-const-enum": "error", + "import/no-cycle": "error", + "import/no-dynamic-require": "error", + "promise/catch-or-return": "error", + "promise/spec-only": "error", + "typescript/no-dynamic-delete": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "error", + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/no-length-as-slice-end": "error", + "unicorn/prefer-number-properties": "error", + "typescript/no-import-type-side-effects": "error", + "unicorn/prefer-event-target": "off", + "unicorn/prefer-add-event-listener": "off", + + // Enforce browser rules + "no-alert": "error", + "no-script-url": "error", + "unicorn/no-document-cookie": "error", + + // Enforce styling + "arrow-body-style": ["error", "as-needed"], + "curly": "error", + "default-case-last": "error", + "default-param-last": "error", + "func-name": ["error", "as-needed"], + "func-style": ["error", "declaration", { "allowArrowFunctions": true }], + "grouped-accessor-pair": ["error", "getBeforeSet"], + "id-length": ["error", { "exceptions": ["_", "t", "z"] }], + "max-params": "warn", + "new-cap": ["error", { "newIsCap": true, "capIsNew": true }], + "no-duplicate-import": "error", + "no-extra-label": "error", + "no-label-var": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-magic-number": [ + "warn", + { + "ignore": [0, 1, 2], + "ignoreDefaultValues": true, + "ignoreClassFieldInitialValues": true, + "ignoreEnums": true, + "ignoreNumericLiteralTypes": true + } + ], + "no-multi-assign": "error", + "no-nested-ternary": "error", + "no-new-func": "error", + "no-return-assign": "error", + "no-template-curly-in-string": "error", + "operator-assignment": ["error", "always"], + "prefer-exponentiation-operator": "error", + "prefer-object-spread": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "import/first": ["error", "absolute-first"], + "import/no-anonymous-default-export": "error", + "import/no-duplicates": "error", + "import/no-mutable-exports": "warn", + "import/no-named-default": "error", + "import/no-namespace": "warn", + "import/no-default-export": "warn", // TODO: move to error once migrated + "promise/avoid-new": "warn", // Maybe error ? + "promise/no-nesting": "error", + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/prefer-await-to-callbacks": "warn", // Maybe error ? + "promise/prefer-await-to-then": "error", // Maybe error ? + "promise/prefer-catch": "error", + "typescript/adjacent-overload-signatures": "error", + "typescript/array-type": ["error", { "default": "array" }], + "typescript/consistent-generic-constructors": "error", + "typescript/consistent-indexed-object-style": ["error", "record"], + "typescript/consistent-type-definitions": ["error", "type"], + "typescript/consistent-type-imports": "error", + "typescript/no-empty-interface": "error", + "typescript/no-inferrable-types": "warn", // Maybe error ? + "typescript/prefer-for-of": "error", + "typescript/prefer-function-type": "error", + "typescript/no-non-null-asserted-nullish-coalescing": "error", + "unicorn/error-message": "error", + "unicorn/no-array-method-this-argument": "error", + "unicorn/no-await-expression-member": "error", + "unicorn/no-console-spaces": "error", + "unicorn/no-zero-fractions": "error", + "unicorn/number-literal-case": "error", + "unicorn/prefer-array-flat-map": "error", + "unicorn/prefer-logical-operator-over-ternary": "error", + "unicorn/no-array-for-each": "error", + "unicorn/no-array-reduce": "warn", + "unicorn/no-magic-array-flat-depth": "error", + "unicorn/no-process-exit": "error", + "unicorn/prefer-modern-math-apis": "error", + "unicorn/prefer-node-protocol": "error", + "typescript/explicit-function-return-type": "warn" + }, + "overrides": [ + { + "files": ["src/composables/**/*.ts", "src/utils/**/*.ts", "*.config.ts"], + "rules": { + "no-default-export": "off" + } + }, + { + "files": ["src/stores/**/*.ts", "src/composables/**/*.ts"], + "rules": { + "max-lines-per-function": "off" + } + } + ] +} diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..69bce8e --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,4 @@ +trailingComma: es5 +tabWidth: 2 +semi: true +singleQuote: true diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..b331dd2 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,23 @@ +{ + "lsp": { + "deno": { + "settings": { + "deno": { + "enable": true + } + } + } + }, + "languages": { + "TypeScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint", + "..." + ], + "formatter": "prettier" + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..17e9f45 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM denoland/deno:2.6.3-alpine + +# Create working directory +WORKDIR /app + +# Copy source +COPY . . + +# Install dependencies +RUN deno install + +# Prepare flags to run script +ENTRYPOINT ["deno", "run", "-P"] diff --git a/bin/reorder-all-series.ts b/bin/reorder-all-series.ts new file mode 100644 index 0000000..0eee49b --- /dev/null +++ b/bin/reorder-all-series.ts @@ -0,0 +1,76 @@ +import { + fetchLibraryMangas, + fetchMangaChapters, + updateMetadata, +} from '~/lib/komga.ts'; + +const getChapterSort = (chapter: any) => { + const volumeNumber = /vol(?:ume)?.?([0-9]+)/i.exec(chapter.name)?.[1] || '1'; + + const chapterNumber = + /ch(?:apter)?.?([0-9.]+)/i.exec(chapter.name)?.[1] || ''; + const chapterFloat = Number.parseFloat(chapterNumber); + + if (!chapterNumber || Number.isNaN(chapterFloat)) { + throw new Error( + `Ch. ${chapterNumber} cannot be parsed from ${chapter.name}` + ); + } + + return { + title: `Ch. ${chapterNumber} - Vol. ${volumeNumber}`, + numberSort: chapterFloat, + }; +}; + +const sanitiseMetadata = (chapter: any, chaptersSeen: Set) => { + const data = getChapterSort(chapter); + + const isDuplicate = chaptersSeen.has(data.numberSort); + if (isDuplicate) { + console.log(`⚠️ Ch. ${data.numberSort} already seen, marking as duplicate`); + } + + chaptersSeen.add(data.numberSort); + + return { + ...data, + numberSortLock: true, + tags: isDuplicate + ? [...chapter.metadata.tags, 'oxy.DUPLICATE'] + : chapter.metadata.tags.filter((tag: string) => tag !== 'oxy.DUPLICATE'), + }; +}; + +// Main + +console.log( + `== Sorting library ${Deno.env.get('LIBRARY_ID')} (${new Date().toISOString()}) ==` +); +for (const manga of await fetchLibraryMangas(Deno.env.get('LIBRARY_ID'))) { + console.log(`Sorting ${manga.name} (${manga.booksCount} chapters)...`); + console.group(); + + const chapters = await fetchMangaChapters(manga.id); + + const chaptersSeen = new Set(); + const metadata = new Map( + chapters.map((chap) => [chap.id, sanitiseMetadata(chap, chaptersSeen)]) + ); + const updates = [...metadata].toSorted( + ([, a], [, b]) => a.numberSort - b.numberSort + ); + + let updateCount = 0; + for (const [id, metadata] of updates) { + await updateMetadata(id, { + ...metadata, + number: updateCount, + }); + + updateCount += 1; + } + + console.log(`✔ Updated ${updateCount} chapters !`); + console.groupEnd(); +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..9cc9bcc --- /dev/null +++ b/deno.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", + + "tasks": { + "format": "deno x prettier --write .", + "lint": "deno x oxlint --fix" + }, + "imports": { + "~": "./", + + "ofetch": "npm:ofetch@^1.5.1" + }, + "permissions": { + "default": { + "env": true, + "net": true + } + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8811f91 --- /dev/null +++ b/deno.lock @@ -0,0 +1,30 @@ +{ + "version": "5", + "specifiers": { + "npm:ofetch@^1.5.1": "1.5.1" + }, + "npm": { + "destr@2.0.5": { + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, + "node-fetch-native@1.6.7": { + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==" + }, + "ofetch@1.5.1": { + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "dependencies": [ + "destr", + "node-fetch-native", + "ufo" + ] + }, + "ufo@1.6.1": { + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==" + } + }, + "workspace": { + "dependencies": [ + "npm:ofetch@^1.5.1" + ] + } +} diff --git a/lib/komga.ts b/lib/komga.ts new file mode 100644 index 0000000..c24c04f --- /dev/null +++ b/lib/komga.ts @@ -0,0 +1,59 @@ +import { ofetch } from 'ofetch'; + +export const komga = ofetch.create({ + baseURL: new URL('/api', Deno.env.get('BASE_URL')!).href, + headers: { + 'X-API-Key': Deno.env.get('API_KEY')!, + }, +}); + +export const fetchLibraryMangas = async ( + libraryId: string +): Promise[]> => { + const { content } = await komga('/v1/series/list', { + method: 'POST', + query: { + unpaged: true, + }, + body: { + condition: { + libraryId: { + operator: 'is', + value: libraryId, + }, + }, + }, + }); + + return content; +}; + +export const fetchMangaChapters = async ( + seriesId: string +): Promise[]> => { + const { content } = await komga('/v1/books/list', { + method: 'POST', + query: { + unpaged: true, + }, + body: { + condition: { + seriesId: { + operator: 'is', + value: seriesId, + }, + }, + }, + }); + + return content; +}; + +export const updateMetadata = ( + id: string, + body: Record +): Promise => + komga(`/v1/books/${id}/metadata`, { + method: 'PATCH', + body, + });