From 3bfda64ddce6e745ed2e02e41227eecaf9a27a83 Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Tue, 6 Oct 2026 04:49:54 -0700 Subject: [PATCH 1/2] add pages to series --- src/page-loaders.ts | 10 +- src/series/index.ts | 9 +- src/tags/search-getters.ts | 4 +- src/urls.ts | 14 +- tests/mocks/data/ao3/series/60910/02.html | 1988 +++++++++++++++++ tests/mocks/data/ao3/series/60910/index.html | 2022 ++++++++++++++++++ tests/mocks/handlers/series/index.ts | 14 +- tests/mocks/scripts/utils.mts | 15 + tests/series.test.ts | 36 + tests/urls.test.ts | 16 + types/entities.ts | 4 + types/zod/generated/schemas.ts | 4 + 12 files changed, 4118 insertions(+), 18 deletions(-) create mode 100644 tests/mocks/data/ao3/series/60910/02.html create mode 100644 tests/mocks/data/ao3/series/60910/index.html diff --git a/src/page-loaders.ts b/src/page-loaders.ts index f49c3b68..ba1d39b0 100644 --- a/src/page-loaders.ts +++ b/src/page-loaders.ts @@ -156,8 +156,14 @@ export const loadChaptersIndexPage = async ({ export interface SeriesPage extends CheerioAPI { kind: "SeriesPage"; } -export const loadSeriesPage = async (seriesId: ArchiveId) => { +export const loadSeriesPage = async ({ + seriesId, + page, +}: { + seriesId: ArchiveId; + page?: number; +}) => { return await fetchPage({ - url: getSeriesUrl({ seriesId }), + url: getSeriesUrl({ seriesId, page }), }); }; diff --git a/src/series/index.ts b/src/series/index.ts index f86c562b..09b45ad9 100644 --- a/src/series/index.ts +++ b/src/series/index.ts @@ -15,17 +15,20 @@ import { import type { Series } from "types/entities"; import { loadSeriesPage } from "src/page-loaders"; +import { getPagesCount } from "src/tags/search-getters"; export const getSeries = async ({ seriesId, + page, }: { seriesId: string | number; + page?: number; }): Promise => { if (!isValidArchiveId(seriesId)) { throw new InvalidIDError(seriesId, "series"); } - const seriesPage = await loadSeriesPage(seriesId); + const seriesPage = await loadSeriesPage({ seriesId, page }); const seriesWorks = getSeriesWorks(seriesPage); @@ -41,6 +44,10 @@ export const getSeries = async ({ bookmarks: getSeriesBookmarkCount(seriesPage), complete: getSeriesCompletionStatus(seriesPage), workCount: getSeriesWorkCount(seriesPage), + pages: { + total: seriesWorks.length > 0 ? Math.max(1, getPagesCount(seriesPage)) : 0, + current: page ?? 1, + }, works: seriesWorks, }; }; diff --git a/src/tags/search-getters.ts b/src/tags/search-getters.ts index 2f1d8c99..9ff306ca 100644 --- a/src/tags/search-getters.ts +++ b/src/tags/search-getters.ts @@ -1,4 +1,4 @@ -import { TagSearchPage, UserWorksPage } from "src/page-loaders"; +import { SeriesPage, TagSearchPage, UserWorksPage } from "src/page-loaders"; import { TagSearchResultSummary } from "types/entities"; const parseIntOrThrow = (text: string) => { @@ -17,7 +17,7 @@ export const getTotalResults = (page: TagSearchPage) => { return totalResultsMatch ? parseIntOrThrow(totalResultsMatch[1]) : 0; }; -export const getPagesCount = (page: TagSearchPage | UserWorksPage) => { +export const getPagesCount = (page: TagSearchPage | UserWorksPage | SeriesPage) => { const lastPageMatch = page(".pagination.actions li:not(.next, .previous)") .last() .text(); diff --git a/src/urls.ts b/src/urls.ts index d53bef55..d01dbb7e 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -72,12 +72,22 @@ export const getWorkIndexUrl = ({ workId }: { workId: string | number }) => { return new URL(`works/${workId}/navigate`, getArchiveBaseUrl()).href; }; -export const getSeriesUrl = ({ seriesId }: { seriesId: string | number }) => { +export const getSeriesUrl = ({ + seriesId, + page, +}: { + seriesId: string | number; + page?: number; +}) => { if (!isValidArchiveId(seriesId)) { throw new InvalidIDError(seriesId, "series"); } - return new URL(`series/${seriesId}`, getArchiveBaseUrl()).href; + const url = new URL(`series/${seriesId}`, getArchiveBaseUrl()); + if (page !== undefined) { + url.searchParams.set("page", String(page)); + } + return url.href; }; export const getAsShortUrl = ({ url }: { url: string | URL }) => { diff --git a/tests/mocks/data/ao3/series/60910/02.html b/tests/mocks/data/ao3/series/60910/02.html new file mode 100644 index 00000000..258dfe02 --- /dev/null +++ b/tests/mocks/data/ao3/series/60910/02.html @@ -0,0 +1,1988 @@ + + + + + + + + + + + + + + + + Fandom Stats - toastystats (destinationtoast) - A Song of Ice and Fire - George R. R. Martin, Check Please! (Webcomic), Elementary (TV), Fandom - Fandom, Fantastic Beasts and Where to Find Them (Movies), Game of Thrones (TV), Goncharov (1973) dir. Martin Scorsese - beelzeebub, Good Omens (TV), Good Omens - Neil Gaiman & Terry Pratchett, Haikyuu!!, Hamilton - Miranda, Harry Potter - J. K. Rowling, Les Misérables - All Media Types, Marvel Cinematic Universe, Once Upon a Time (TV), Queen (Band), Real Person Fiction, Riverdale (TV 2017), Shadowhunters (TV), Sherlock (TV), Sherlock Holmes & Related Fandoms, Sherlock Holmes (Downey films), Sherlock Holmes - Arthur Conan Doyle, Star Wars - All Media Types, Star Wars Sequel Trilogy, Stranger Things (TV 2016), Succession (TV 2018), Supernatural, Supernatural (TV 2005), Ted Lasso (TV), The Hobbit - All Media Types, The Lord of the Rings - All Media Types, The Silmarillion and other histories of Middle-Earth - J. R. R. Tolkien, The Wheel of Time (TV), Transformers - All Media Types, Voltron: Legendary Defender, Wheel of Time - Robert Jordan, 僕のヒーローアカデミア | Boku no Hero Academia | My Hero Academia [Archive of Our Own] + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + +
+ +
+ +

+ Fandom Stats +

+ + + + + + + +

Series Metadata

+
+
+ +
Creator:
+
+ +
Series Begun:
+
2013-01-13
+
Series Updated:
+
2025-07-24
+ +
Notes:
+

I gather and share fandom data for fun! Among other things, this series includes:

  • +

    Biggest/most active fandoms over time (also a bunch about biggest ships and characters)

  • +

    Shipping-related stats – including femslash, polyamory, asexuality and aromanticism, and gen, as well a look at the similarities and differences between F/F, F/M, and M/M

  • +

    Gender stats - including Trans/NB stats & movies vs. fanworks

  • +

    Trope stats – including AU stats and Omegaverse stats

  • +

    Cross-platform comparisons (Wattpad, FFNet, AO3, etc)

  • +

    Fandom deep dives – Sherlockian and otherwise, including how TV fandom fix-its relate to canon events across many fandoms

  • +

    Misc – popularity, ratings, warnings, languages, completion

  • +

    Reference – Fandom stats talks and tutorials

  • +

    Note that everything is in roughly chronological order (except where I've made mistakes), so you'll want to go to the end of the series to see my most recent stuff.

    + +
    Stats:
    +
    +
    +
    Words:
    +
    163,416
    +
    Works:
    +
    131
    +
    Complete:
    +
    No
    +
    Bookmarks:
    +
    348
    +
    +
    + +
    +
    + +

    Listing Series

    + +

    Pages Navigation

    + + + +

    Pages Navigation

    + + + + + +
    +
    + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/mocks/data/ao3/series/60910/index.html b/tests/mocks/data/ao3/series/60910/index.html new file mode 100644 index 00000000..109200fa --- /dev/null +++ b/tests/mocks/data/ao3/series/60910/index.html @@ -0,0 +1,2022 @@ + + + + + + + + + + + + + + + + Fandom Stats - toastystats (destinationtoast) - A Song of Ice and Fire - George R. R. Martin, Check Please! (Webcomic), Elementary (TV), Fandom - Fandom, Fantastic Beasts and Where to Find Them (Movies), Game of Thrones (TV), Goncharov (1973) dir. Martin Scorsese - beelzeebub, Good Omens (TV), Good Omens - Neil Gaiman & Terry Pratchett, Haikyuu!!, Hamilton - Miranda, Harry Potter - J. K. Rowling, Les Misérables - All Media Types, Marvel Cinematic Universe, Once Upon a Time (TV), Queen (Band), Real Person Fiction, Riverdale (TV 2017), Shadowhunters (TV), Sherlock (TV), Sherlock Holmes & Related Fandoms, Sherlock Holmes (Downey films), Sherlock Holmes - Arthur Conan Doyle, Star Wars - All Media Types, Star Wars Sequel Trilogy, Stranger Things (TV 2016), Succession (TV 2018), Supernatural, Supernatural (TV 2005), Ted Lasso (TV), The Hobbit - All Media Types, The Lord of the Rings - All Media Types, The Silmarillion and other histories of Middle-Earth - J. R. R. Tolkien, The Wheel of Time (TV), Transformers - All Media Types, Voltron: Legendary Defender, Wheel of Time - Robert Jordan, 僕のヒーローアカデミア | Boku no Hero Academia | My Hero Academia [Archive of Our Own] + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + +
    + + + + +
    + +
    + +

    + Fandom Stats +

    + + + + + + + +

    Series Metadata

    +
    +
    + +
    Creator:
    +
    + +
    Series Begun:
    +
    2013-01-13
    +
    Series Updated:
    +
    2025-07-24
    + +
    Notes:
    +

    I gather and share fandom data for fun! Among other things, this series includes:

  • +

    Biggest/most active fandoms over time (also a bunch about biggest ships and characters)

  • +

    Shipping-related stats – including femslash, polyamory, asexuality and aromanticism, and gen, as well a look at the similarities and differences between F/F, F/M, and M/M

  • +

    Gender stats - including Trans/NB stats & movies vs. fanworks

  • +

    Trope stats – including AU stats and Omegaverse stats

  • +

    Cross-platform comparisons (Wattpad, FFNet, AO3, etc)

  • +

    Fandom deep dives – Sherlockian and otherwise, including how TV fandom fix-its relate to canon events across many fandoms

  • +

    Misc – popularity, ratings, warnings, languages, completion

  • +

    Reference – Fandom stats talks and tutorials

  • +

    Note that everything is in roughly chronological order (except where I've made mistakes), so you'll want to go to the end of the series to see my most recent stuff.

    + +
    Stats:
    +
    +
    +
    Words:
    +
    163,416
    +
    Works:
    +
    131
    +
    Complete:
    +
    No
    +
    Bookmarks:
    +
    348
    +
    +
    + +
    +
    + +

    Listing Series

    + +

    Pages Navigation

    + + + +

    Pages Navigation

    + + + + + +
    +
    + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/mocks/handlers/series/index.ts b/tests/mocks/handlers/series/index.ts index aafb52ef..7bc7d22b 100644 --- a/tests/mocks/handlers/series/index.ts +++ b/tests/mocks/handlers/series/index.ts @@ -1,19 +1,11 @@ import fs from "fs"; -import path from "path"; import { http, HttpHandler, HttpResponse } from "msw"; -import { getArchiveDataDir, safeFilenamify } from "../../scripts/utils.mjs"; +import { getFilePathFromUrl } from "../../scripts/utils.mjs"; export default http.all( "https://archiveofourown.org/series/:series_id", - ({ params }) => { - const html = fs.readFileSync( - path.resolve( - getArchiveDataDir(), - "series", - safeFilenamify(params.series_id as string), - "index.html" - ) - ); + ({ request }) => { + const html = fs.readFileSync(getFilePathFromUrl(request.url)); return new HttpResponse(html, { headers: { "Content-Type": "text/html" }, diff --git a/tests/mocks/scripts/utils.mts b/tests/mocks/scripts/utils.mts index cc7b8b23..356dc4fc 100644 --- a/tests/mocks/scripts/utils.mts +++ b/tests/mocks/scripts/utils.mts @@ -279,6 +279,12 @@ export function getUrlFromPath( const segments = urlPath.split(path.sep).filter(Boolean); + if (segments[0] === "series" && /^\d+\.html$/.test(filename)) { + const url = new URL(`/${segments.join("/")}`, getArchiveUrl(archive)); + url.searchParams.set("page", String(parseInt(filename, 10))); + return url.href; + } + if ( segments[0] === "users" && segments[segments.length - 2] === "works" && @@ -334,6 +340,15 @@ export function getFilePathFromUrl(url: string | URL) { const lastSegment = segments[segments.length - 1]; const hasExtension = lastSegment?.includes("."); + if (segments[0] === "series" && parsedUrl.searchParams.has("page")) { + const pageFileName = getPageFileName(parsedUrl.searchParams); + return path.join( + getArchiveDataDir(archive), + ...segments.map((segment) => safeFilenamify(decodeURIComponent(segment))), + pageFileName === "01.html" ? "index.html" : pageFileName, + ); + } + if (segments[0] === "tags" && lastSegment === "search") { return path.join( getArchiveDataDir(archive), diff --git a/tests/series.test.ts b/tests/series.test.ts index 72ad59ee..97bfb886 100644 --- a/tests/series.test.ts +++ b/tests/series.test.ts @@ -430,3 +430,39 @@ describe("Series/title", () => { expect(series.name).toBe("*Insert Fandom* but Social Media (one-shots)"); }); }); + +describe("Series/pagination", () => { + it("should fetch the first page with no page parameter", async () => { + const series = await getSeries({ seriesId: 60910 }); + + expect(series.name).toBe("Fandom Stats"); + expect(series.pages).toEqual({ total: 7, current: 1 }); + expect(series.works.map((work) => work.id)).toEqual([ + 16890957, 1026780, 1026854, 16876149, 16891416, + 16926933, 16932096, 16933218, 17009412, 17009619, + 17009676, 17011452, 17011545, 17011641, 17011701, + 17011803, 17012055, 17012217, 1608254, 1704926, + ]); + }); + + it("should fetch the requested page", async () => { + const series = await getSeries({ seriesId: 60910, page: 2 }); + + expect(series.name).toBe("Fandom Stats"); + expect(series.pages).toEqual({ total: 7, current: 2 }); + expect(series.works.map((work) => work.id)).toEqual([ + 1706072, 1708259, 2076084, 16844275, 16861600, + 16890750, 16890834, 16910946, 16913802, 16913982, + 16925628, 17014116, 17013705, 17009067, 17009250, + 17012409, 17012583, 16965927, 17002176, 16933407, + ]); + }); + + it("should return one page for a series without pagination", async () => { + const series = await getSeries({ seriesId: 2270465 }); + + expect(series.pages).toEqual({ total: 1, current: 1 }); + expect(series.works).toHaveLength(6); + }); + +}); diff --git a/tests/urls.test.ts b/tests/urls.test.ts index e56508bf..b5377439 100644 --- a/tests/urls.test.ts +++ b/tests/urls.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { getAuthorFromUrl, getSearchUrlFromTagFilters, + getSeriesUrl, getTagUrl, getTagWorksFeedUrl, getUserDetailsFromUrl, @@ -540,3 +541,18 @@ describe("getUserDetailsFromUrl", () => { ); }); }); + + +describe("getSeriesUrl", () => { + it("should preserve the URL without a page", () => { + expect(getSeriesUrl({ seriesId: 2270465 })).toBe( + "https://archiveofourown.org/series/2270465", + ); + }); + + it("should add an optional page", () => { + expect(getSeriesUrl({ seriesId: "2270465", page: 2 })).toBe( + "https://archiveofourown.org/series/2270465?page=2", + ); + }); +}); diff --git a/types/entities.ts b/types/entities.ts index 036c52b3..09ead6fe 100644 --- a/types/entities.ts +++ b/types/entities.ts @@ -149,6 +149,10 @@ export interface Series { bookmarks: number; complete: boolean; workCount: number; + pages: { + total: number; + current: number; + }; works: WorkBlurbSummary[]; } diff --git a/types/zod/generated/schemas.ts b/types/zod/generated/schemas.ts index 08c4d553..a32ad341 100644 --- a/types/zod/generated/schemas.ts +++ b/types/zod/generated/schemas.ts @@ -182,6 +182,10 @@ export const seriesSchema = z.object({ bookmarks: z.number(), complete: z.boolean(), workCount: z.number(), + pages: z.object({ + total: z.number(), + current: z.number() + }), works: z.array(workBlurbSummarySchema) }); From dc0f56a5efdcbf45f3a93dedccde2595e8d5c9ae Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Tue, 6 Oct 2026 04:51:35 -0700 Subject: [PATCH 2/2] Add zod4 support --- package-lock.json | 4 +- package.json | 10 +- tsdown.config.ts | 3 +- types/zod/fix-schemas.mjs | 19 ++- types/zod/generated/schemas-v4.ts | 200 ++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 types/zod/generated/schemas-v4.ts diff --git a/package-lock.json b/package-lock.json index 393d06e2..8fb196eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,10 +28,10 @@ "vite-plugin-dts": "^4.5.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4", - "zod": "^3.23.8" + "zod": "^3.25.0" }, "peerDependencies": { - "zod": "^3.0.0 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { "zod": { diff --git a/package.json b/package.json index 4e961381..3d8cd0b9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,12 @@ "types": "./dist/zod-schemas.d.ts", "default": "./dist/zod-schemas.js" } + }, + "./zod-schemas/v4": { + "import": { + "types": "./dist/zod-schemas-v4.d.ts", + "default": "./dist/zod-schemas-v4.js" + } } }, "files": [ @@ -64,10 +70,10 @@ "vite-plugin-dts": "^4.5.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4", - "zod": "^3.23.8" + "zod": "^3.25.0" }, "peerDependencies": { - "zod": "^3.0.0 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { "zod": { diff --git a/tsdown.config.ts b/tsdown.config.ts index f002b4ab..75ad6bd6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,10 +8,11 @@ export default defineConfig([ index: resolve(import.meta.dirname, "src/index.ts"), urls: resolve(import.meta.dirname, "src/urls.ts"), "zod-schemas": resolve(import.meta.dirname, "types/zod/generated/schemas.ts"), + "zod-schemas-v4": resolve(import.meta.dirname, "types/zod/generated/schemas-v4.ts"), }, dts: true, clean: true, unbundle: true, - external: ["zod"], + external: [/^zod(\/.*)?$/], }, ]); diff --git a/types/zod/fix-schemas.mjs b/types/zod/fix-schemas.mjs index f53eb2cb..d09f9962 100644 --- a/types/zod/fix-schemas.mjs +++ b/types/zod/fix-schemas.mjs @@ -1,7 +1,8 @@ /** - * Post-processes schemas generated by ts-to-zod to fix enum handling: ts-to-zod - * generates z.enum(EnumName) but Zod v3 requires z.nativeEnum(EnumName) for - * TypeScript enums. + * ts-to-zod generates Zod 4 code, but we still support Zod 3 consumers. + * This post-processing is needed to produce both entry points: the Zod 4 + * file explicitly imports zod/v4, while the existing Zod 3 file uses + * z.nativeEnum() for TypeScript enums. Both come from the same generator run. */ import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; @@ -9,13 +10,9 @@ import { resolve } from "path"; const ENUM_PATTERN = /z\.enum\(([A-Z][a-zA-Z0-9]*)\)/g; const schemasPath = resolve(import.meta.dirname, "generated/schemas.ts"); +const v4SchemasPath = resolve(import.meta.dirname, "generated/schemas-v4.ts"); const content = readFileSync(schemasPath, "utf-8"); -const fixedContent = content.replace(ENUM_PATTERN, "z.nativeEnum($1)"); +writeFileSync(v4SchemasPath, content.replace('from "zod"', 'from "zod/v4"')); +writeFileSync(schemasPath, content.replace(ENUM_PATTERN, "z.nativeEnum($1)")); -const matches = content.match(ENUM_PATTERN); -if (matches) { - console.log(`✔ Fixed ${matches.length} enum schemas to use z.nativeEnum()`); -} - -writeFileSync(schemasPath, fixedContent); -console.log("✔ Generated types/zod/schemas.ts"); +console.log("✔ Generated Zod 3 and Zod 4 schemas"); diff --git a/types/zod/generated/schemas-v4.ts b/types/zod/generated/schemas-v4.ts new file mode 100644 index 00000000..9095815b --- /dev/null +++ b/types/zod/generated/schemas-v4.ts @@ -0,0 +1,200 @@ +// Generated by ts-to-zod +import { z } from "zod/v4"; +import { WorkRatings, WorkCategory, WorkWarningStatus, WorkWarnings } from "./../../entities"; + +export const archiveIdSchema = z.number(); + +export const tagCategorySchema = z.union([z.literal("fandom"), z.literal("character"), z.literal("relationship"), z.literal("archive warning"), z.literal("additional tags")]); + +export const tagSchema = z.object({ + name: z.string(), + id: archiveIdSchema.nullable(), + category: tagCategorySchema, + canonical: z.boolean(), + common: z.boolean(), + canonicalName: z.string().nullable(), + parentTags: z.array(z.string()), + childTags: z.array(z.object({ + tagName: z.string(), + category: tagCategorySchema + })), + subTags: z.array(z.object({ + tagName: z.string(), + parentSubTag: z.string().nullable() + })), + tagsWithSameMeaning: z.array(z.string()) +}); + +export const tagSearchTypeSchema = z.union([z.literal("fandom"), z.literal("character"), z.literal("relationship"), z.literal("freeform"), z.literal("any")]); + +export const tagWranglingStatusSchema = z.union([z.literal("canonical"), z.literal("noncanonical"), z.literal("synonymous"), z.literal("canonical_or_synonymous"), z.literal("noncanonical_and_nonsynonymous"), z.literal("any")]); + +export const tagSearchFiltersSchema = z.object({ + tagName: z.string().nullable(), + fandoms: z.array(z.string()), + type: tagSearchTypeSchema, + wranglingStatus: tagWranglingStatusSchema, + sortColumn: z.union([z.literal("name"), z.literal("created_at"), z.literal("works_count")]), + sortDirection: z.union([z.literal("asc"), z.literal("desc")]), + page: z.number() +}); + +export const tagSearchResultSummarySchema = z.object({ + filters: tagSearchFiltersSchema, + totalResults: z.number(), + pages: z.object({ + total: z.number(), + current: z.number() + }), + tags: z.array(z.object({ + name: z.string(), + type: z.union([z.literal("fandom"), z.literal("character"), z.literal("relationship"), z.literal("freeform"), z.literal("unsorted")]), + canonical: z.boolean(), + worksCount: z.number() + })) +}); + +export const userSchema = z.object({ + id: archiveIdSchema, + username: z.string(), + pseuds: z.string(), + url: z.string(), + shortUrl: z.string(), + icon: z.string(), + header: z.string().nullable(), + joined: z.string(), + location: z.string().nullable(), + birthday: z.string().nullable(), + works: z.number(), + series: z.number(), + bookmarks: z.number(), + collections: z.number(), + gifts: z.number(), + bioHtml: z.string().nullable() +}); + +export const userWorksFiltersSchema = z.object({ + sortColumn: z.union([z.literal("authors"), z.literal("title"), z.literal("created_at"), z.literal("updated_at"), z.literal("word_count"), z.literal("hits"), z.literal("kudos_count"), z.literal("comments_count"), z.literal("bookmarks_count")]), + sortDirection: z.union([z.literal("asc"), z.literal("desc")]), + page: z.number() +}); + +export const workRatingsSchema = z.enum(WorkRatings); + +export const workCategorySchema = z.enum(WorkCategory); + +export const workWarningStatusSchema = z.enum(WorkWarningStatus); + +export const workWarningsSchema = z.enum(WorkWarnings); + +export const basicSeriesSchema = z.object({ + id: archiveIdSchema, + index: z.number(), + name: z.string() +}); + +export const authorSchema = z.object({ + username: z.string(), + pseud: z.string(), + anonymous: z.boolean() +}); + +export const workSummarySchema = z.object({ + id: archiveIdSchema, + title: z.string(), + category: z.array(workCategorySchema).nullable(), + publishedAt: z.string(), + updatedAt: z.string().nullable(), + summary: z.string().nullable(), + rating: workRatingsSchema, + adult: z.boolean(), + fandoms: z.array(z.string()), + tags: z.object({ + warnings: z.array(workWarningsSchema), + characters: z.array(z.string()), + relationships: z.array(z.string()), + additional: z.array(z.string()) + }), + authors: z.array(authorSchema), + language: z.string(), + words: z.number(), + chapters: z.object({ + published: z.number(), + total: z.number().nullable() + }), + chapterInfo: z.object({ + id: archiveIdSchema, + index: z.number(), + name: z.string().nullable(), + summary: z.string().nullable() + }).nullable(), + series: z.array(basicSeriesSchema), + complete: z.boolean(), + stats: z.object({ + bookmarks: z.number(), + comments: z.number(), + kudos: z.number(), + hits: z.number() + }), + locked: z.literal(false) +}); + +export const workContentSchema = z.object({ + summary: z.string().nullable(), + startNotes: z.string().nullable(), + content: z.string(), + endNotes: z.string().nullable() +}); + +export const lockedWorkSummarySchema = z.object({ + id: archiveIdSchema, + locked: z.literal(true) +}); + +export const chapterSchema = z.object({ + id: archiveIdSchema, + workId: archiveIdSchema, + index: z.number(), + title: z.string(), + publishedAt: z.string(), + url: z.string(), + shortUrl: z.string() +}); + +export const workBlurbSummarySchema = workSummarySchema.omit({ "category": true, "publishedAt": true, "rating": true, "tags": true, "stats": true, "locked": true, "chapterInfo": true, "series": true }).extend({ + url: z.string(), + shortUrl: z.string(), + tags: workSummarySchema.shape.tags.omit({ "warnings": true }), + stats: workSummarySchema.shape.stats.omit({ "comments": true }) +}); + +export const seriesWorkSummarySchema = workBlurbSummarySchema; + +export const seriesSchema = z.object({ + id: archiveIdSchema, + name: z.string(), + startedAt: z.string(), + updatedAt: z.string(), + authors: workSummarySchema.shape.authors, + description: z.string().nullable(), + notes: z.string().nullable(), + words: z.number(), + bookmarks: z.number(), + complete: z.boolean(), + workCount: z.number(), + pages: z.object({ + total: z.number(), + current: z.number() + }), + works: z.array(workBlurbSummarySchema) +}); + +export const userWorksSchema = z.object({ + filters: userWorksFiltersSchema, + totalResults: z.number(), + pages: z.object({ + total: z.number(), + current: z.number() + }), + works: z.array(workBlurbSummarySchema) +});