From 9cff775cf427eded56e16cee43c58c5a95a6a7cd Mon Sep 17 00:00:00 2001 From: luisdralves Date: Fri, 22 Sep 2023 14:34:32 +0100 Subject: [PATCH] Migrate fully to sqlite based backend --- .gitignore | 4 + src/api/banknotes/get-country-banknotes.ts | 24 --- src/api/coins/get-country-coins.ts | 24 --- src/api/countries/get-country-counts.ts | 68 +++++++++ src/api/index.ts | 3 + src/api/types/get-type.ts | 14 ++ src/api/types/get-types.ts | 165 +++++++++++++++++++++ src/api/types/relations.ts | 13 ++ src/pages/api/country-counts.ts | 18 +++ src/pages/api/merge-textures/[id].ts | 73 ++++----- src/pages/api/types.ts | 79 ++++++++++ src/pages/banknotes/[country].tsx | 31 ++-- src/pages/coins/[country].tsx | 32 ++-- src/pages/index.tsx | 63 +++----- src/pages/pile/[country].tsx | 25 ++-- 15 files changed, 478 insertions(+), 158 deletions(-) delete mode 100644 src/api/banknotes/get-country-banknotes.ts delete mode 100644 src/api/coins/get-country-coins.ts create mode 100644 src/api/countries/get-country-counts.ts create mode 100644 src/api/index.ts create mode 100644 src/api/types/get-type.ts create mode 100644 src/api/types/get-types.ts create mode 100644 src/api/types/relations.ts create mode 100644 src/pages/api/country-counts.ts create mode 100644 src/pages/api/types.ts diff --git a/.gitignore b/.gitignore index bbfa7a9..a687448 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ next-env.d.ts # fetchable assets public/photos + +# sqlite cache +main.db-shm +main.db-wal diff --git a/src/api/banknotes/get-country-banknotes.ts b/src/api/banknotes/get-country-banknotes.ts deleted file mode 100644 index 9be2c93..0000000 --- a/src/api/banknotes/get-country-banknotes.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NumistaType } from "@/types/numista"; -import { readFile, readdir } from "fs/promises"; -import path from "path"; - -export const getCountryBanknotes = async (country: string) => { - try { - const banknoteIds = await readdir(path.join("./public/banknotes", country)); - - const banknotes: NumistaType[] = []; - - for (const banknoteId of banknoteIds) { - const banknote = await readFile( - path.join("./public/banknotes", country, banknoteId), - { encoding: "utf-8" }, - ); - - banknotes.push(JSON.parse(banknote)); - } - - return banknotes; - } catch { - return []; - } -}; diff --git a/src/api/coins/get-country-coins.ts b/src/api/coins/get-country-coins.ts deleted file mode 100644 index f2a6294..0000000 --- a/src/api/coins/get-country-coins.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NumistaType } from "@/types/numista"; -import { readFile, readdir } from "fs/promises"; -import path from "path"; - -export const getCountryCoins = async (country: string) => { - try { - const coinIds = await readdir(path.join("./public/coins", country)); - - const coins: NumistaType[] = []; - - for (const coinId of coinIds) { - const coin = await readFile( - path.join("./public/coins", country, coinId), - { encoding: "utf-8" }, - ); - - coins.push(JSON.parse(coin)); - } - - return coins; - } catch { - return []; - } -}; diff --git a/src/api/countries/get-country-counts.ts b/src/api/countries/get-country-counts.ts new file mode 100644 index 0000000..59574b5 --- /dev/null +++ b/src/api/countries/get-country-counts.ts @@ -0,0 +1,68 @@ +import { prisma } from "@/api"; + +export const getCountryCounts = async (category?: "coins" | "banknotes") => { + const countryBanknoteCounts = + category !== "coins" + ? await prisma.numistaType.groupBy({ + by: ["issuer_code"], + _sum: { + count: true, + }, + _count: true, + where: { + category: { + equals: "banknote", + }, + }, + }) + : []; + + const countryCoinCounts = + category !== "banknotes" + ? await prisma.numistaType.groupBy({ + by: ["issuer_code"], + _sum: { + count: true, + }, + _count: true, + where: { + category: { + not: "banknote", + }, + }, + }) + : []; + + const countries = await prisma.issuer.findMany({ + orderBy: { + name: "asc", + }, + }); + + const map = countries.map((country) => { + const banknoteCounts = countryBanknoteCounts.find( + ({ issuer_code }) => issuer_code === country.code, + ); + const coinCounts = countryCoinCounts.find( + ({ issuer_code }) => issuer_code === country.code, + ); + + return { + ...country, + ...(banknoteCounts && { + banknotes: { + sum: banknoteCounts?._sum?.count, + count: banknoteCounts?._count, + }, + }), + ...(coinCounts && { + coins: { + sum: coinCounts?._sum?.count, + count: coinCounts?._count, + }, + }), + }; + }); + + return map; +}; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/src/api/types/get-type.ts b/src/api/types/get-type.ts new file mode 100644 index 0000000..d089701 --- /dev/null +++ b/src/api/types/get-type.ts @@ -0,0 +1,14 @@ +import { NumistaType } from "@/types/numista"; +import { prisma } from "@/api"; +import { include } from "./relations"; + +export const getType = async (id: number) => { + const type = await prisma.numistaType.findUnique({ + include, + where: { + id, + }, + }); + + return type as unknown as NumistaType | null; +}; diff --git a/src/api/types/get-types.ts b/src/api/types/get-types.ts new file mode 100644 index 0000000..1361c82 --- /dev/null +++ b/src/api/types/get-types.ts @@ -0,0 +1,165 @@ +import { NumistaCategory, NumistaType } from "@/types/numista"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/api"; +import { include } from "./relations"; + +type Props = { + search?: string; + category?: Exclude; + country?: string; + currency?: string; + faceValue?: number; + yearRange?: [number, number]; + special?: "yes" | "no" | "both"; +}; + +export const getTypes = async ({ + search, + category, + country, + currency, + faceValue, + yearRange, + special, +}: Props) => { + const AND: Prisma.NumistaTypeWhereInput[] = []; + const OR: Prisma.NumistaTypeWhereInput[] = []; + + if (category === "banknote") { + AND.push({ + category, + }); + } + + if (category === "coin") { + AND.push({ + category: { + not: "banknote", + }, + }); + } + + if (country) { + AND.push({ + issuer: { + OR: [ + { + code: country, + }, + { + name: country, + }, + ], + }, + }); + } + + if (currency) { + AND.push({ + value: { + currency: { + OR: [{ name: currency }, { full_name: currency }], + }, + }, + }); + } + + if (faceValue) { + AND.push({ + value: { + numeric_value: { + equals: faceValue, + }, + }, + }); + } + + if (yearRange?.length === 2) { + AND.push({ + AND: { + min_year: { + lte: yearRange[0], + }, + max_year: { + gte: yearRange[1], + }, + }, + }); + } + + if (special === "yes") { + AND.push({ + NOT: { + type: { + contains: "Standard", + }, + }, + }); + } + + if (special === "no") { + AND.push({ + type: { + contains: "Standard", + }, + }); + } + + if (search) { + OR.push({ title: { contains: search } }); + OR.push({ + issuer: { + OR: [ + { + code: country, + }, + { + name: country, + }, + ], + }, + }); + OR.push({ + value: { + currency: { + OR: [{ name: currency }, { full_name: currency }], + }, + }, + }); + } + + if (AND.length) { + OR.push({ AND }); + } + + const types = await prisma.numistaType.findMany({ + include, + ...(OR.length > 0 + ? { + where: { + OR, + }, + } + : {}), + orderBy: [ + { + issuer: { + name: "asc", + }, + }, + { + min_year: "asc", + }, + { + max_year: "asc", + }, + { + value: { + numeric_value: "asc", + }, + }, + ], + }); + + return types as unknown as NumistaType[]; +}; diff --git a/src/api/types/relations.ts b/src/api/types/relations.ts new file mode 100644 index 0000000..d2aa665 --- /dev/null +++ b/src/api/types/relations.ts @@ -0,0 +1,13 @@ +import { Prisma } from "@prisma/client"; + +export const include: Prisma.NumistaTypeInclude = { + issuer: true, + obverse: true, + reverse: true, + edge: true, + value: { + include: { + currency: true, + }, + }, +}; diff --git a/src/pages/api/country-counts.ts b/src/pages/api/country-counts.ts new file mode 100644 index 0000000..45f2f9a --- /dev/null +++ b/src/pages/api/country-counts.ts @@ -0,0 +1,18 @@ +import { getCountryCounts } from "@/api/countries/get-country-counts"; +import type { NextApiHandler } from "next"; + +const handler: NextApiHandler = async (_req, res) => { + try { + const countryCounts = await getCountryCounts(); + + res.status(200).json(countryCounts); + } catch (error) { + console.error(error); + res.status(500).json({ + message: `Something went wrong`, + error: String(error), + }); + } +}; + +export default handler; diff --git a/src/pages/api/merge-textures/[id].ts b/src/pages/api/merge-textures/[id].ts index aab6304..28b76ad 100644 --- a/src/pages/api/merge-textures/[id].ts +++ b/src/pages/api/merge-textures/[id].ts @@ -1,7 +1,8 @@ import type { NextApiHandler } from "next"; -import { readFile, readdir } from "fs/promises"; +import { readFile } from "fs/promises"; import path from "path"; import { spawn } from "child_process"; +import { getType } from "@/api/types/get-type"; const asyncSpawn = (command: string, args: string[]) => { return new Promise((resolve, reject) => { @@ -22,19 +23,7 @@ const asyncSpawn = (command: string, args: string[]) => { }); }; -const getCoinPath = async (id: string) => { - const countries = await readdir("./public/coins"); - for (const country of countries) { - const coins = await readdir(path.join("./public/coins", country)); - for (const coin of coins) { - if (coin === id + ".json") { - return path.join("./public/coins", country, coin); - } - } - } -}; - -const getPreviousResult = async (id: string) => { +const getPreviousResult = async (id: number) => { try { return await readFile(path.join("./public/photos/joined", id + ".jpg")); } catch { @@ -42,35 +31,51 @@ const getPreviousResult = async (id: string) => { } }; -const generateImage = async (id: string) => { +const generateImage = async (id: number) => { const previousResult = await getPreviousResult(id); if (previousResult) { return previousResult; } - const coinPath = await getCoinPath(id); + const coin = await getType(id); - if (!coinPath) { + if (!coin) { return; } - const coin = JSON.parse(await readFile(coinPath, { encoding: "utf-8" })); + if (coin.obverse?.picture) { + await asyncSpawn("convert", [ + path.join("./public", coin.obverse.picture), + "-resize", + "256x256!", + path.join("./public/photos/joined", id + "-obverse.jpg"), + ]); + } else { + await asyncSpawn("convert", [ + "-size", + "512x64", + "xc:" + coin.color, + path.join("./public/photos/joined", id + "-edge.jpg"), + ]); + } - await asyncSpawn("convert", [ - path.join("./public", coin.obverse.picture), - "-resize", - "256x256!", - path.join("./public/photos/joined", id + "-obverse.jpg"), - ]); - - await asyncSpawn("convert", [ - path.join("./public", coin.reverse.picture), - "-resize", - "256x256!", - ...(coin.orientation === "coin" ? ["-rotate", "180"] : []), - path.join("./public/photos/joined", id + "-reverse.jpg"), - ]); + if (coin.reverse?.picture) { + await asyncSpawn("convert", [ + path.join("./public", coin.reverse.picture), + "-resize", + "256x256!", + ...(coin.orientation === "coin" ? ["-rotate", "180"] : []), + path.join("./public/photos/joined", id + "-reverse.jpg"), + ]); + } else { + await asyncSpawn("convert", [ + "-size", + "512x64", + "xc:" + coin.color, + path.join("./public/photos/joined", id + "-edge.jpg"), + ]); + } if (coin.edge?.picture) { await asyncSpawn("convert", [ @@ -108,7 +113,7 @@ const generateImage = async (id: string) => { }; const handler: NextApiHandler = async ({ query }, res) => { - if (typeof query.id !== "string") { + if (!Number(query.id)) { res.status(400).json({ message: `Invalid id ${JSON.stringify(query.id)}`, }); @@ -116,7 +121,7 @@ const handler: NextApiHandler = async ({ query }, res) => { } try { - const image = await generateImage(query.id); + const image = await generateImage(Number(query.id)); if (!image) { res.status(404).json({ diff --git a/src/pages/api/types.ts b/src/pages/api/types.ts new file mode 100644 index 0000000..e6f1f09 --- /dev/null +++ b/src/pages/api/types.ts @@ -0,0 +1,79 @@ +import { getTypes } from "@/api/types/get-types"; +import type { NextApiHandler } from "next"; + +const handler: NextApiHandler = async ({ query }, res) => { + if (Array.isArray(query.search)) { + res.status(400).json({ + message: `Invalid search ${JSON.stringify(query.search)}`, + }); + return; + } + if (Array.isArray(query.currency)) { + res.status(400).json({ + message: `Invalid currency ${JSON.stringify(query.currency)}`, + }); + return; + } + if ( + Array.isArray(query.faceValue) || + (query.faceValue && !Number(query.faceValue)) + ) { + res.status(400).json({ + message: `Invalid faceValue ${JSON.stringify(query.faceValue)}`, + }); + return; + } + if ( + Array.isArray(query.category) || + (query.category && !["coin", "banknote"].includes(query.category)) + ) { + res.status(400).json({ + message: `Invalid category ${JSON.stringify(query.category)}`, + }); + return; + } + if ( + Array.isArray(query.special) || + (query.special && !["yes", "no", "both"].includes(query.special)) + ) { + res.status(400).json({ + message: `Invalid special filter ${JSON.stringify(query.special)}`, + }); + return; + } + if ( + !Array.isArray(query.yearRange) || + query.yearRange.length !== 2 || + query.yearRange.some((year) => !Number(year)) + ) { + res.status(400).json({ + message: `Invalid year range ${JSON.stringify(query.yearRange)}`, + }); + return; + } + + try { + const coins = await getTypes({ + search: query.search, + category: query.category as "coin" | "banknote" | undefined, + faceValue: Number(query.faceValue), + currency: query.currency, + special: query.special as "yes" | "no" | "both" | undefined, + yearRange: query.yearRange.map(Number) as [number, number], + }); + + if (!coins.length) { + return res.status(404).json(coins); + } + + res.status(200).json(coins); + } catch (error) { + console.error(error); + res.status(500).json({ + message: `Something went wrong`, + error: String(error), + }); + } +}; + +export default handler; diff --git a/src/pages/banknotes/[country].tsx b/src/pages/banknotes/[country].tsx index 2718d37..01b2202 100644 --- a/src/pages/banknotes/[country].tsx +++ b/src/pages/banknotes/[country].tsx @@ -1,12 +1,10 @@ import Head from "next/head"; import { GetStaticPaths, GetStaticProps, NextPage } from "next"; import { NumistaType } from "@/types/numista"; -import { readdir } from "fs/promises"; import { Showcase } from "@/components/showcase"; import { Header } from "@/components/header"; -import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes"; -import { getCountryCoins } from "@/api/coins/get-country-coins"; -import { sortTypes } from "@/core/utils/numista"; +import { getTypes } from "@/api/types/get-types"; +import { getCountryCounts } from "@/api/countries/get-country-counts"; type Props = { country: string; @@ -18,10 +16,13 @@ type Props = { const BanknotesPage: NextPage = ({ banknotes, ...props }) => ( <> - Banknotes + {props.country + " - Banknotes"} @@ -43,9 +44,15 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - const coins = await getCountryCoins(params.country); - const banknotes = await getCountryBanknotes(params.country); + const coins = await getTypes({ + category: "coin", + country: params.country, + }); + const banknotes = await getTypes({ + category: "banknote", + country: params.country, + }); return { props: { country: params.country, @@ -54,17 +61,19 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { (total, banknote) => total + (banknote.count ?? 1), 0, ), - banknotes: sortTypes(banknotes), + banknotes: banknotes, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const countries = await readdir("./public/banknotes"); + const countries = await getCountryCounts("banknotes"); return { fallback: false, - paths: countries.map((country) => ({ params: { country } })), + paths: countries + .filter((country) => country.banknotes?.count) + .map((country) => ({ params: { country: country.name } })), }; }; diff --git a/src/pages/coins/[country].tsx b/src/pages/coins/[country].tsx index 0ef2b90..0f1644a 100644 --- a/src/pages/coins/[country].tsx +++ b/src/pages/coins/[country].tsx @@ -1,12 +1,10 @@ import Head from "next/head"; import { GetStaticPaths, GetStaticProps, NextPage } from "next"; import { NumistaType } from "@/types/numista"; -import { readdir } from "fs/promises"; import { Showcase } from "@/components/showcase"; import { Header } from "@/components/header"; -import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes"; -import { getCountryCoins } from "@/api/coins/get-country-coins"; -import { sortTypes } from "@/core/utils/numista"; +import { getTypes } from "@/api/types/get-types"; +import { getCountryCounts } from "@/api/countries/get-country-counts"; type Props = { country: string; @@ -18,10 +16,13 @@ type Props = { const CoinsPage: NextPage = ({ coins, ...props }) => ( <> - Coins + {props.country + " - Coins"} @@ -43,8 +44,15 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - const coins = await getCountryCoins(params.country); - const banknotes = await getCountryBanknotes(params.country); + const coins = await getTypes({ + category: "coin", + country: params.country, + }); + + const banknotes = await getTypes({ + category: "banknote", + country: params.country, + }); return { props: { @@ -54,17 +62,19 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { 0, ), coinCount: coins.reduce((total, coin) => total + (coin.count ?? 1), 0), - coins: sortTypes(coins), + coins, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const countries = await readdir("./public/coins"); + const countries = await getCountryCounts("coins"); return { fallback: false, - paths: countries.map((country) => ({ params: { country } })), + paths: countries + .filter((country) => country.coins?.count) + .map((country) => ({ params: { country: country.name } })), }; }; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ad2c217..d895563 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,26 +1,12 @@ import Head from "next/head"; import { GetStaticProps, NextPage } from "next"; -import { readdir } from "fs/promises"; -import { useMemo } from "react"; -import { getCountryCoins } from "@/api/coins/get-country-coins"; -import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes"; +import { getCountryCounts } from "@/api/countries/get-country-counts"; type Props = { - countsByCountry: Record< - string, - { - coinCount: number; - banknoteCount: number; - } - >; + countryCounts: Awaited>; }; -const CountriesPage: NextPage = ({ countsByCountry }) => { - const countries = useMemo( - () => Object.keys(countsByCountry).sort(), - [countsByCountry], - ); - +const CountriesPage: NextPage = ({ countryCounts }) => { return ( <> @@ -40,9 +26,11 @@ const CountriesPage: NextPage = ({ countsByCountry }) => { padding: "32px 16px", }} > - {countries.map((country) => ( -
- {country} + {countryCounts.map((country) => ( +
+ + {country.name} +
= ({ countsByCountry }) => { width: "min(640px, 95vw)", }} > - {countsByCountry[country].coinCount > 0 ? ( + {country.coins?.count ? ( <> - - {"pile (" + countsByCountry[country].coinCount + ")"} + + {"pile (" + country.coins.sum + ")"} - - {"coins (" + countsByCountry[country].coinCount + ")"} + + {"coins (" + country.coins.count + ")"} ) : ( @@ -70,9 +58,9 @@ const CountriesPage: NextPage = ({ countsByCountry }) => { )} - {countsByCountry[country].banknoteCount > 0 ? ( - - {"banknotes (" + countsByCountry[country].banknoteCount + ")"} + {country.banknotes?.count ? ( + + {"banknotes (" + country.banknotes.count + ")"} ) : (
@@ -86,26 +74,11 @@ const CountriesPage: NextPage = ({ countsByCountry }) => { }; export const getStaticProps: GetStaticProps = async () => { - const coinCountries = await readdir("./public/coins"); - const banknoteCountries = await readdir("./public/banknotes"); - const countsByCountry: Props["countsByCountry"] = {}; - - for (const country of new Set([...coinCountries, ...banknoteCountries])) { - countsByCountry[country] = { - coinCount: (await getCountryCoins(country)).reduce( - (total, coin) => total + (coin.count ?? 1), - 0, - ), - banknoteCount: (await getCountryBanknotes(country)).reduce( - (total, banknote) => total + (banknote.count ?? 1), - 0, - ), - }; - } + const countryCounts = await getCountryCounts(); return { props: { - countsByCountry, + countryCounts, }, }; }; diff --git a/src/pages/pile/[country].tsx b/src/pages/pile/[country].tsx index cb884cf..352ba06 100644 --- a/src/pages/pile/[country].tsx +++ b/src/pages/pile/[country].tsx @@ -2,11 +2,9 @@ import Head from "next/head"; import { Pile } from "@/components/pile"; import { GetStaticPaths, GetStaticProps, NextPage } from "next"; import { NumistaType } from "@/types/numista"; -import { readdir } from "fs/promises"; import { Header } from "@/components/header"; -import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes"; -import { getCountryCoins } from "@/api/coins/get-country-coins"; -import { sortTypes } from "@/core/utils/numista"; +import { getCountryCounts } from "@/api/countries/get-country-counts"; +import { getTypes } from "@/api/types/get-types"; type Props = { country: string; @@ -43,8 +41,15 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - const coins = await getCountryCoins(params.country); - const banknotes = await getCountryBanknotes(params.country); + const coins = await getTypes({ + category: "coin", + country: params.country, + }); + + const banknotes = await getTypes({ + category: "banknote", + country: params.country, + }); return { props: { @@ -54,17 +59,19 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { 0, ), coinCount: coins.reduce((total, coin) => total + (coin.count ?? 1), 0), - coins: sortTypes(coins), + coins, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const countries = await readdir("./public/coins"); + const countries = await getCountryCounts("coins"); return { fallback: false, - paths: countries.map((country) => ({ params: { country } })), + paths: countries + .filter((country) => country.coins?.count) + .map((country) => ({ params: { country: country.name } })), }; };