feat: refactor whole bunch of stuff

This commit is contained in:
Max Richter
2025-11-02 19:03:11 +01:00
parent 81ebc8f5e0
commit e6b90cb785
56 changed files with 753 additions and 360 deletions

80
lib/marka/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { MARKA_API_KEY } from "../env.ts";
import { getImage } from "../image.ts";
import { GenericResource } from "./schema.ts";
//const url = `https://marka.max-richter.dev`;
const url = "http://localhost:8080";
async function addImageToResource<T extends GenericResource>(
resource: GenericResource,
): Promise<T> {
const imageUrl = resource?.content?.image;
if (imageUrl) {
try {
const absoluteImageUrl = (imageUrl.startsWith("https://") ||
imageUrl.startsWith("http://"))
? imageUrl
: `${url}/${imageUrl}`;
const image = await getImage(absoluteImageUrl);
return { ...resource, image } as T;
} catch (e) {
console.log(`Failed to fetch image: ${imageUrl}`, e);
}
}
return resource as T;
}
export async function fetchResource<T extends GenericResource>(
resource: string,
): Promise<T | undefined> {
try {
const response = await fetch(
`${url}/resources/${resource}`,
);
const res = await response.json();
return addImageToResource<T>(res);
} catch (_e) {
return;
}
}
export async function listResources<T = GenericResource>(
resource: string,
): Promise<T[]> {
try {
const response = await fetch(
`${url}/resources/${resource}`,
);
const list = await response.json();
return Promise.all(
list?.content
.filter((a: GenericResource) => a?.content?._type)
.map((res: GenericResource) => addImageToResource(res)),
);
} catch (_e) {
return [];
}
}
export async function createResource(
path: string,
content: string | object | ArrayBuffer,
) {
const isJson = typeof content === "object" &&
!(content instanceof ArrayBuffer);
const fetchUrl = `${url}/resources/${path}`;
const headers = new Headers();
headers.append("Content-Type", isJson ? "application/json" : "");
if (MARKA_API_KEY) {
headers.append("Authentication", MARKA_API_KEY);
}
const response = await fetch(fetchUrl, {
method: "POST",
headers,
body: isJson ? JSON.stringify(content) : content,
});
if (!response.ok) {
throw new Error(`Failed to create resource: ${response.status}`);
}
return response.json();
}

126
lib/marka/schema.ts Normal file
View File

@@ -0,0 +1,126 @@
import { z } from "zod";
import { imageTable } from "../db/schema.ts";
export const PersonSchema = z.object({
_type: z.literal("Person"),
name: z.string().optional(),
});
export const ReviewRatingSchema = z.object({
bestRating: z.number().optional(),
worstRating: z.number().optional(),
// Accept number or string (e.g., "⭐️⭐️⭐️⭐️⭐️")
ratingValue: z.union([z.number(), z.string()]).optional(),
});
const WithAuthor = z.object({ author: PersonSchema.optional() });
const WithKeywords = z.object({ keywords: z.array(z.string()).optional() });
const WithImage = z.object({ image: z.string().optional() });
const WithDatePublished = z.object({ datePublished: z.string().optional() });
const BaseContent = WithAuthor.merge(WithKeywords)
.merge(WithImage)
.merge(WithDatePublished);
export const BaseFileSchema = z.object({
type: z.literal("file"),
name: z.string(),
path: z.string(),
modTime: z.string(), // ISO timestamp string
mime: z.string(),
size: z.number().int().nonnegative(),
});
const makeContentSchema = <
TName extends "Article" | "Review" | "Recipe",
TShape extends z.ZodRawShape,
>(
name: TName,
shape: TShape,
) =>
z
.object({
_type: z.literal(name),
keywords: z.array(z.string()).optional(),
})
.merge(BaseContent)
.extend(shape);
export const ArticleContentSchema = makeContentSchema("Article", {
headline: z.string().optional(),
articleBody: z.string().optional(),
url: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
});
export const ReviewContentSchema = makeContentSchema("Review", {
tmdbId: z.number().optional(),
link: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
reviewBody: z.string().optional(),
itemReviewed: z
.object({
name: z.string().optional(),
})
.optional(),
});
export const RecipeContentSchema = makeContentSchema("Recipe", {
description: z.string().optional(),
name: z.string().optional(),
recipeIngredient: z.array(z.string()).optional(),
recipeInstructions: z.array(z.string()).optional(),
totalTime: z.string().optional(),
recipeYield: z.number().optional(),
url: z.string().optional(),
});
export const articleMetadataSchema = z.object({
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
author: z.union([z.null(), z.string()]).describe("Author of the article"),
datePublished: z.union([z.null(), z.string()]).describe(
"Date the article was published",
),
keywords: z.union([z.null(), z.array(z.string())]).describe(
"Keywords for the article",
),
});
export const ArticleSchema = BaseFileSchema.extend({
content: ArticleContentSchema,
});
export const ReviewSchema = BaseFileSchema.extend({
content: ReviewContentSchema,
});
export const RecipeSchema = BaseFileSchema.extend({
content: RecipeContentSchema,
});
export const GenericResourceSchema = z.union([
ArticleSchema,
ReviewSchema,
RecipeSchema,
]);
export type Person = z.infer<typeof PersonSchema>;
export type ReviewRating = z.infer<typeof ReviewRatingSchema>;
export type BaseFile = z.infer<typeof BaseFileSchema>;
export type ArticleResource = z.infer<typeof ArticleSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type ReviewResource = z.infer<typeof ReviewSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type RecipeResource = z.infer<typeof RecipeSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
image?: typeof imageTable.$inferSelect;
};