Migrate fully to sqlite based backend

This commit is contained in:
2023-09-22 14:34:32 +01:00
parent 03fb7268a8
commit 9cff775cf4
15 changed files with 478 additions and 158 deletions
+4
View File
@@ -37,3 +37,7 @@ next-env.d.ts
# fetchable assets
public/photos
# sqlite cache
main.db-shm
main.db-wal
@@ -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 [];
}
};
-24
View File
@@ -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 [];
}
};
+68
View File
@@ -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;
};
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
+14
View File
@@ -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;
};
+165
View File
@@ -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<NumistaCategory, "exonumia">;
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[];
};
+13
View File
@@ -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,
},
},
};
+18
View File
@@ -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;
+39 -34
View File
@@ -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({
+79
View File
@@ -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;
+20 -11
View File
@@ -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<Props> = ({ banknotes, ...props }) => (
<>
<Head>
<title>Banknotes</title>
<title>{props.country + " - Banknotes"}</title>
<meta
name="description"
content="An exhibition of my father's banknote collection"
content={
"An exhibition of my father's collection of banknotes from " +
props.country
}
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
@@ -43,9 +44,15 @@ export const getStaticProps: GetStaticProps<Props> = 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<Props> = 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 } })),
};
};
+21 -11
View File
@@ -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<Props> = ({ coins, ...props }) => (
<>
<Head>
<title>Coins</title>
<title>{props.country + " - Coins"}</title>
<meta
name="description"
content="An exhibition of my father's coin collection"
content={
"An exhibition of my father's collection of coins from " +
props.country
}
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
@@ -43,8 +44,15 @@ export const getStaticProps: GetStaticProps<Props> = 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<Props> = 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 } })),
};
};
+18 -45
View File
@@ -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<ReturnType<typeof getCountryCounts>>;
};
const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
const countries = useMemo(
() => Object.keys(countsByCountry).sort(),
[countsByCountry],
);
const CountriesPage: NextPage<Props> = ({ countryCounts }) => {
return (
<>
<Head>
@@ -40,9 +26,11 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
padding: "32px 16px",
}}
>
{countries.map((country) => (
<div key={country} style={{ maxWidth: 640, margin: "auto" }}>
<b style={{ textAlign: "center", display: "block" }}>{country}</b>
{countryCounts.map((country) => (
<div key={country.code} style={{ maxWidth: 640, margin: "auto" }}>
<b style={{ textAlign: "center", display: "block" }}>
{country.name}
</b>
<div
style={{
@@ -54,13 +42,13 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
width: "min(640px, 95vw)",
}}
>
{countsByCountry[country].coinCount > 0 ? (
{country.coins?.count ? (
<>
<a href={"/pile/" + country}>
{"pile (" + countsByCountry[country].coinCount + ")"}
<a href={"/pile/" + country.name}>
{"pile (" + country.coins.sum + ")"}
</a>
<a href={"/coins/" + country}>
{"coins (" + countsByCountry[country].coinCount + ")"}
<a href={"/coins/" + country.name}>
{"coins (" + country.coins.count + ")"}
</a>
</>
) : (
@@ -70,9 +58,9 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
</>
)}
{countsByCountry[country].banknoteCount > 0 ? (
<a href={"/banknotes/" + country}>
{"banknotes (" + countsByCountry[country].banknoteCount + ")"}
{country.banknotes?.count ? (
<a href={"/banknotes/" + country.name}>
{"banknotes (" + country.banknotes.count + ")"}
</a>
) : (
<div />
@@ -86,26 +74,11 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
};
export const getStaticProps: GetStaticProps<Props> = 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,
},
};
};
+16 -9
View File
@@ -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<Props> = 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<Props> = 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 } })),
};
};