diff --git a/components/Card.tsx b/components/Card.tsx index 1cea3e5..6eff7b4 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -11,13 +11,13 @@ export function Card( rating, title, image, - thumbnail, + thumbhash, backgroundColor, backgroundSize = 100, }: { backgroundSize?: number; backgroundColor?: string; - thumbnail?: string; + thumbhash?: string; link?: string; title?: string; image?: string; @@ -39,22 +39,20 @@ export function Card( - {true && ( - - - - )} + + +
diff --git a/components/Image.tsx b/components/Image.tsx index 7b5d435..7dd7c7b 100644 --- a/components/Image.tsx +++ b/components/Image.tsx @@ -34,7 +34,7 @@ const Image = ( class: string; src: string; alt?: string; - thumbnail?: string; + thumbhash?: string; fill?: boolean; width?: number | string; height?: number | string; @@ -55,19 +55,17 @@ const Image = ( height: props.fill ? "100%" : "", zIndex: props.fill ? -1 : "", }} - data-thumb={props.thumbnail} + data-thumb={props.thumbhash} > {props.alt}({ +const HeroContext = createContext<{ image?: string; thumbhash?: string }>({ image: undefined, - thumbnail: undefined, + thumbhash: undefined, }); function Wrapper( - { children, image, thumbnail }: { + { children, image, thumbhash }: { children: ComponentChildren; image?: string; - thumbnail?: string; + thumbhash?: string; }, ) { return (
{image && @@ -30,7 +29,7 @@ function Wrapper( Recipe Banner

diff --git a/drizzle/0010_youthful_tyrannus.sql b/drizzle/0010_youthful_tyrannus.sql new file mode 100644 index 0000000..01f1491 --- /dev/null +++ b/drizzle/0010_youthful_tyrannus.sql @@ -0,0 +1 @@ +ALTER TABLE `image` RENAME COLUMN "blurhash" TO "thumbhash"; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..3d6d9fe --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,311 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "685b57ca-45e0-4373-baee-fc3abb4f2d74", + "prevId": "5694a345-e55c-4aa3-9f29-1045b28f5203", + "tables": { + "cache": { + "name": "cache", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "json": { + "name": "json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "binary": { + "name": "binary", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "key_idx": { + "name": "key_idx", + "columns": [ + "key" + ], + "isUnique": false + }, + "scope_idx": { + "name": "scope_idx", + "columns": [ + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document": { + "name": "document", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "perm": { + "name": "perm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "name_idx": { + "name": "name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image": { + "name": "image", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "average": { + "name": "average", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbhash": { + "name": "thumbhash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "performance": { + "name": "performance", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search": { + "name": "search", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(STRFTIME('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"image\".\"blurhash\"": "\"image\".\"thumbhash\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4a32ec0..c4b474b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1736172911816, "tag": "0009_free_robin_chapel", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1762099260474, + "tag": "0010_youthful_tyrannus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/islands/IngredientsList.tsx b/islands/IngredientsList.tsx index 33ed81c..e37d49d 100644 --- a/islands/IngredientsList.tsx +++ b/islands/IngredientsList.tsx @@ -2,7 +2,7 @@ import { Signal } from "@preact/signals"; import type { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts"; import { FunctionalComponent } from "preact"; import { unitsOfMeasure } from "@lib/parseIngredient.ts"; -import { renderMarkdown } from "@lib/documents.ts"; +import { renderMarkdown } from "@lib/markdown.ts"; function formatAmount(num: number) { if (num === 0) return ""; diff --git a/islands/KMenu/commands/add_movie_infos.ts b/islands/KMenu/commands/add_movie_infos.ts index deb809f..11d02f2 100644 --- a/islands/KMenu/commands/add_movie_infos.ts +++ b/islands/KMenu/commands/add_movie_infos.ts @@ -1,7 +1,7 @@ - -import { Movie } from "@lib/resource/movies.ts"; import { TMDBMovie } from "@lib/types.ts"; import { getCookie } from "@lib/string.ts"; +import { MenuEntry } from "../types.ts"; +import { ReviewResource } from "@lib/marka/schema.ts"; export const addMovieInfos: MenuEntry = { title: "Add Movie infos", @@ -9,7 +9,7 @@ export const addMovieInfos: MenuEntry = { icon: "IconReportSearch", cb: async (state, context) => { state.activeState.value = "loading"; - const movie = context as Movie; + const movie = context as ReviewResource; const query = movie.name; @@ -33,7 +33,7 @@ export const addMovieInfos: MenuEntry = { }); state.visible.value = false; state.activeState.value = "normal"; - window.location.reload(); + globalThis.location.reload(); }, })), }; diff --git a/islands/KMenu/commands/add_series_infos.ts b/islands/KMenu/commands/add_series_infos.ts index c168a22..9461547 100644 --- a/islands/KMenu/commands/add_series_infos.ts +++ b/islands/KMenu/commands/add_series_infos.ts @@ -1,7 +1,7 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { TMDBSeries } from "@lib/types.ts"; import { getCookie } from "@lib/string.ts"; -import { Series } from "@lib/resource/series.ts"; +import { ReviewResource } from "@lib/marka/schema.ts"; export const addSeriesInfo: MenuEntry = { title: "Add Series infos", @@ -9,7 +9,7 @@ export const addSeriesInfo: MenuEntry = { icon: "IconReportSearch", cb: async (state, context) => { state.activeState.value = "loading"; - const series = context as Series; + const series = context as ReviewResource; const query = series.name; diff --git a/islands/KMenu/commands/create_movie.ts b/islands/KMenu/commands/create_movie.ts index 7bbf597..d2e2b8b 100644 --- a/islands/KMenu/commands/create_movie.ts +++ b/islands/KMenu/commands/create_movie.ts @@ -1,8 +1,8 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { TMDBMovie } from "@lib/types.ts"; import { debounce } from "@lib/helpers.ts"; -import { Movie } from "@lib/resource/movies.ts"; import { getCookie } from "@lib/string.ts"; +import { ReviewResource } from "@lib/marka/schema.ts"; export const createNewMovie: MenuEntry = { title: "Create new movie", @@ -52,9 +52,9 @@ export const createNewMovie: MenuEntry = { const response = await fetch("/api/movies/" + r.id, { method: "POST", }); - const movie = await response.json() as Movie; + const movie = await response.json() as ReviewResource; unsub(); - window.location.href = "/movies/" + movie.name; + globalThis.location.href = "/movies/" + movie.name; }, }; }), diff --git a/islands/KMenu/commands/create_series.ts b/islands/KMenu/commands/create_series.ts index 51f5725..9987e79 100644 --- a/islands/KMenu/commands/create_series.ts +++ b/islands/KMenu/commands/create_series.ts @@ -1,8 +1,8 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { TMDBSeries } from "@lib/types.ts"; import { debounce } from "@lib/helpers.ts"; -import { Series } from "@lib/resource/series.ts"; import { getCookie } from "@lib/string.ts"; +import { ReviewResource } from "@lib/marka/schema.ts"; export const createNewSeries: MenuEntry = { title: "Create new series", @@ -54,9 +54,9 @@ export const createNewSeries: MenuEntry = { const response = await fetch("/api/series/" + r.id, { method: "POST", }); - const series = await response.json() as Series; + const series = await response.json() as ReviewResource; unsub(); - window.location.href = "/series/" + series.name; + globalThis.location.href = "/series/" + series.name; }, }; }), diff --git a/islands/Link.tsx b/islands/Link.tsx index ea8e4ec..014e184 100644 --- a/islands/Link.tsx +++ b/islands/Link.tsx @@ -6,13 +6,16 @@ declare global { } export function Link( - { href, children, class: _class, style }: { + props: { href?: string; class?: string; style?: preact.JSX.CSSProperties; children: preact.ComponentChildren; + "data-thumb"?: string; }, ) { + const { href, children, class: _class, style } = props; + const thumbhash = props["data-thumb"]; function handleClick() { if (globalThis.loadingTimeout) { return; @@ -41,6 +44,7 @@ export function Link( href={href} style={style} onClick={handleClick} + data-thumb={thumbhash} class={_class} > {children} diff --git a/islands/Search.tsx b/islands/Search.tsx index b1aa4fe..d822eac 100644 --- a/islands/Search.tsx +++ b/islands/Search.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef } from "preact/hooks"; import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts"; import { IconLoader2, IconSearch } from "@components/icons.tsx"; import { useEventListener } from "@lib/hooks/useEventListener.ts"; -import { GenericResource } from "@lib/types.ts"; import { resources } from "@lib/resources.ts"; import { getCookie } from "@lib/string.ts"; import { IS_BROWSER } from "$fresh/runtime.ts"; @@ -11,6 +10,7 @@ import { Rating } from "@components/Rating.tsx"; import { useSignal } from "@preact/signals"; import Image from "@components/Image.tsx"; import { Emoji } from "@components/Emoji.tsx"; +import { GenericResource } from "@lib/marka/schema.ts"; export async function fetchQueryResource(url: URL, type = "") { const query = url.searchParams.get("q"); @@ -46,7 +46,7 @@ export const RedirectSearchHandler = () => { }, IS_BROWSER ? document?.body : undefined); } - return <>; + return; }; const SearchResultImage = ({ src }: { src: string }) => { @@ -67,8 +67,9 @@ export const SearchResultItem = ( showEmoji?: boolean; }, ) => { - const resourceType = resources[item.type]; - const href = resourceType ? `${resourceType.link}/${item.id}` : ""; + const resourceType = resources[item?.content._type]; + const href = item?.path.replace("/resources", "").replace(/\.md$/, ""); + console.log({ item, href }); return ( : ""} - {item.meta?.image && } - {item?.name} + {item.image && } + {item.content?.headline || item.content?.name || + item.content?.itemReviewed.name || item?.name} ); }; @@ -190,16 +192,16 @@ const Search = ( {data.value?.length && !isLoading.value ? : isLoading.value - ?
- : ( -
- - No Results -
- )} + ?
+ : ( +
+ + No Results +
+ )}
); }; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 034a58f..90ca58c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -47,7 +47,7 @@ export const imageTable = sqliteTable("image", { ), url: text().notNull(), average: text().notNull(), - blurhash: text().notNull(), + thumbhash: text().notNull(), mime: text().notNull(), }); diff --git a/lib/helpers.ts b/lib/helpers.ts index b57c695..f0c8b23 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -104,12 +104,13 @@ export function debounce) => void>( export function parseRating(rating: string | number) { if (typeof rating === "string") { try { - return parseInt(rating); + const res = parseInt(rating); + if (!Number.isNaN(res)) return res; } catch (_e) { // This is okay } - return [...rating.matchAll(/⭐/g)].length; + return rating.length / 2; } return rating; } diff --git a/lib/image.ts b/lib/image.ts index 6a1e2b4..5fc0046 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -3,7 +3,7 @@ import { createLogger } from "@lib/log/index.ts"; import { generateThumbhash } from "@lib/thumbhash.ts"; import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts"; import path from "node:path"; -import { ensureDir } from "fs"; +import { mkdir } from "node:fs/promises"; import { DATA_DIR } from "@lib/env.ts"; import { db } from "@lib/db/sqlite.ts"; import { imageTable } from "@lib/db/schema.ts"; @@ -13,7 +13,7 @@ import sharp from "npm:sharp@next"; const log = createLogger("cache/image"); const imageDir = path.join(DATA_DIR, "images"); -await ensureDir(imageDir); +await mkdir(imageDir, { recursive: true }); async function getRemoteImage(imageUrl: string) { try { @@ -100,7 +100,7 @@ async function getLocalImagePath( hostname, pathname.split("/").filter((s) => s.length).join("-"), ); - await ensureDir(imagePath); + await mkdir(imagePath, { recursive: true }); if (width || height) { imagePath = path.join(imagePath, `${width ?? "-"}x${height ?? "-"}`); @@ -313,7 +313,7 @@ export async function getImage(url: string) { // Store in database const [newImage] = await db.insert(imageTable).values({ url: url, - blurhash: thumbhash.hash, + thumbhash: thumbhash.hash, average: thumbhash.average, mime: imageContent.mediaType, }).returning(); diff --git a/lib/log/constants.ts b/lib/log/constants.ts index b50ea66..24e0082 100644 --- a/lib/log/constants.ts +++ b/lib/log/constants.ts @@ -1,12 +1,12 @@ import * as env from "@lib/env.ts"; -import { ensureDir } from "fs"; import { join } from "node:path"; import { getLogLevel, LOG_LEVEL } from "@lib/log/types.ts"; +import { mkdir } from "node:fs/promises"; export const LOG_DIR = join(env.DATA_DIR, "logs"); // Ensure the log directory exists -await ensureDir(LOG_DIR); +await mkdir(LOG_DIR, { recursive: true }); export let logLevel = getLogLevel(env.LOG_LEVEL); export function setLogLevel(level: LOG_LEVEL) { diff --git a/lib/marka.ts b/lib/marka.ts deleted file mode 100644 index 7f587d8..0000000 --- a/lib/marka.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MARKA_API_KEY } from "./env.ts"; -const url = `https://marka.max-richter.dev/resources`; -//const url = "http://localhost:8080/resources"; - -export async function fetchResource(resource: string) { - try { - const response = await fetch( - `${url}/${resource}`, - ); - return response.json(); - } catch (_e) { - return []; - } -} - -export async function createResource( - path: string, - content: string | object | ArrayBuffer, -) { - const isJson = typeof content === "object" && - !(content instanceof ArrayBuffer); - const fetchUrl = `${url}/${path}`; - const response = await fetch(fetchUrl, { - method: "POST", - headers: { - "Content-Type": isJson ? "application/json" : "", - "Authentication": MARKA_API_KEY, - }, - body: isJson ? JSON.stringify(content) : content, - }); - if (!response.ok) { - throw new Error(`Failed to create resource: ${response.status}`); - } - return response.json(); -} diff --git a/lib/marka/index.ts b/lib/marka/index.ts new file mode 100644 index 0000000..a16b031 --- /dev/null +++ b/lib/marka/index.ts @@ -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( + resource: GenericResource, +): Promise { + 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( + resource: string, +): Promise { + try { + const response = await fetch( + `${url}/resources/${resource}`, + ); + const res = await response.json(); + return addImageToResource(res); + } catch (_e) { + return; + } +} + +export async function listResources( + resource: string, +): Promise { + 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(); +} diff --git a/lib/marka/schema.ts b/lib/marka/schema.ts new file mode 100644 index 0000000..a704b56 --- /dev/null +++ b/lib/marka/schema.ts @@ -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; +export type ReviewRating = z.infer; + +export type BaseFile = z.infer; + +export type ArticleResource = z.infer & { + image?: typeof imageTable.$inferSelect; +}; + +export type ReviewResource = z.infer & { + image?: typeof imageTable.$inferSelect; +}; + +export type RecipeResource = z.infer & { + image?: typeof imageTable.$inferSelect; +}; + +export type GenericResource = z.infer & { + image?: typeof imageTable.$inferSelect; +}; diff --git a/lib/documents.ts b/lib/markdown.ts similarity index 89% rename from lib/documents.ts rename to lib/markdown.ts index 71fe185..42c0521 100644 --- a/lib/documents.ts +++ b/lib/markdown.ts @@ -44,11 +44,3 @@ export function renderMarkdown(doc: string) { allowMath: true, }); } - -export function createDocument( - path: string, - entry: string, - mimetype = "image/jpeg", -) { - console.log("creating", { path, entry, mimetype }); -} diff --git a/lib/openai.ts b/lib/openai.ts index d065e48..2145ad8 100644 --- a/lib/openai.ts +++ b/lib/openai.ts @@ -4,7 +4,7 @@ import { OPENAI_API_KEY } from "@lib/env.ts"; import { hashString } from "@lib/helpers.ts"; import { createCache } from "@lib/cache.ts"; import { recipeResponseSchema } from "@lib/recipeSchema.ts"; -import { articleMetadataSchema } from "./resource/articles.ts"; +import { articleMetadataSchema } from "./marka/schema.ts"; const openAI = OPENAI_API_KEY && new OpenAI({ apiKey: OPENAI_API_KEY }); diff --git a/lib/recipeSchema.ts b/lib/recipeSchema.ts index 9edcd94..d5dcbfa 100644 --- a/lib/recipeSchema.ts +++ b/lib/recipeSchema.ts @@ -1,4 +1,5 @@ import { z } from "npm:zod"; +import { RecipeResource } from "./marka/schema.ts"; export const IngredientSchema = z.object({ quantity: z.string().describe( @@ -17,7 +18,6 @@ export const IngredientGroupSchema = z.object({ export type IngredientGroup = z.infer; const recipeSchema = z.object({ - _type: z.literal("Recipe"), name: z.string().describe( "Title of the Recipe, without the name of the website or author", ), @@ -29,6 +29,9 @@ const recipeSchema = z.object({ _type: z.literal("Person"), name: z.string().describe("author of the Recipe (optional)"), }), + keywords: z.array(z.string()).describe( + "List of keywords that match the recipe", + ), recipeIngredient: z.array(z.string()) .describe("List of ingredients"), recipeInstructions: z.array(z.string()).describe("List of instructions"), @@ -48,7 +51,7 @@ export const recipeResponseSchema = z.union([recipeSchema, noRecipeSchema]); export function isValidRecipe( recipe: - | Recipe + | RecipeResource | null | undefined, ) { diff --git a/lib/recommendation.ts b/lib/recommendation.ts index 8dbca87..38cde98 100644 --- a/lib/recommendation.ts +++ b/lib/recommendation.ts @@ -1,8 +1,8 @@ import * as openai from "@lib/openai.ts"; import * as tmdb from "@lib/tmdb.ts"; -import { GenericResource } from "@lib/types.ts"; import { parseRating } from "@lib/helpers.ts"; import { createCache } from "@lib/cache.ts"; +import { GenericResource, ReviewResource } from "./marka/schema.ts"; type RecommendationResource = { id: string; @@ -18,42 +18,48 @@ type RecommendationResource = { const cache = createCache("recommendations"); export async function createRecommendationResource( - res: GenericResource, + res: ReviewResource, description?: string, ) { - const cacheId = `${res.type}:${res.id.replaceAll(":", "")}`; + const cacheId = `${res.type}:${res.name.replaceAll(":", "")}`; const resource = cache.get(cacheId) || { - id: res.id, + id: res.name, type: res.type, rating: -1, }; if (description && !resource.keywords) { - const keywords = await openai.createKeywords(res.type, description, res.id); + const keywords = await openai.createKeywords( + res.type, + description, + res.name, + ); if (keywords?.length) { resource.keywords = keywords; } } - const { author, date, rating } = res.meta || {}; + const { author, datePublished, reviewRating } = res.content; - if (res?.tags) { - resource.tags = res.tags; + if (res?.content?.keywords) { + resource.keywords = res.content.keywords; } - if (typeof rating !== "undefined") { - resource.rating = parseRating(rating); + if (typeof reviewRating?.ratingValue !== "undefined") { + resource.rating = parseRating(reviewRating?.ratingValue); } - if (author) { - resource.author = author; + if (author?.name) { + resource.author = author.name; } if (description) { resource.description = description; } - if (date) { - const d = typeof date === "string" ? new Date(date) : date; + if (datePublished) { + const d = typeof datePublished === "string" + ? new Date(datePublished) + : datePublished; resource.year = d.getFullYear(); } diff --git a/lib/resource/articles.ts b/lib/resource/articles.ts deleted file mode 100644 index a55d47c..0000000 --- a/lib/resource/articles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; -export type Article = { - _type: "Article"; - headline?: string; - datePublished?: string; - articleBody?: string; - keywords?: string[]; - image?: string; - url?: string; - reviewRating?: { - bestRating?: number; - worstRating?: number; - ratingValue?: number; - }; - author?: { - _type: "Person"; - name?: string; - }; -}; - -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", - ), -}); diff --git a/lib/resource/movies.ts b/lib/resource/movies.ts deleted file mode 100644 index 18b33e5..0000000 --- a/lib/resource/movies.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type Movie = { - _type: "Review"; - tmdbId?: number; - link?: string; - author?: { - _type: "Person"; - name?: string; - }; - datePublished?: string; - reviewRating?: { - bestRating?: number; - worstRating?: number; - ratingValue?: number; - }; - reviewBody?: string; - itemReviewed?: { - name?: string; - }; - keywords?: string[]; - image?: string; -}; diff --git a/lib/resource/recipes.ts b/lib/resource/recipes.ts deleted file mode 100644 index 2697270..0000000 --- a/lib/resource/recipes.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type Recipe = { - _type: "Recipe"; - author?: { - _type: "Person"; - name?: string; - }; - description?: string; - image?: string; - name?: string; - recipeIngredient?: string[]; - recipeInstructions?: string[]; - datePublished?: string; - totalTime?: string; - recipeYield?: number; - url?: string; - keywords?: string[]; -}; diff --git a/lib/resource/series.ts b/lib/resource/series.ts deleted file mode 100644 index b6fc136..0000000 --- a/lib/resource/series.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Movie } from "./movies.ts"; - -export type Series = Movie; diff --git a/lib/search.ts b/lib/search.ts index 669d98e..084448f 100644 --- a/lib/search.ts +++ b/lib/search.ts @@ -1,12 +1,8 @@ import { resources } from "@lib/resources.ts"; import fuzzysort from "npm:fuzzysort"; -import { GenericResource } from "@lib/types.ts"; import { extractHashTags } from "@lib/string.ts"; -import { Movie } from "@lib/resource/movies.ts"; -import { Article } from "@lib/resource/articles.ts"; -import { Recipe } from "@lib/resource/recipes.ts"; -import { Series } from "@lib/resource/series.ts"; -import { fetchResource } from "./marka.ts"; +import { listResources } from "./marka/index.ts"; +import { GenericResource } from "./marka/schema.ts"; type ResourceType = keyof typeof resources; @@ -48,8 +44,8 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined { } const isResource = ( - item: Movie | Series | Article | Recipe | boolean, -): item is Movie | Series | Article | Recipe => { + item: GenericResource | boolean | undefined, +): item is GenericResource => { return !!item; }; @@ -57,10 +53,10 @@ export async function searchResource( { q, tags = [], types, rating }: SearchParams, ): Promise { const resources = (await Promise.all([ - (!types || types.includes("movie")) && fetchResource("movies"), - (!types || types.includes("series")) && fetchResource("series"), - (!types || types.includes("article")) && fetchResource("articles"), - (!types || types.includes("recipe")) && fetchResource("recipes"), + (!types || types.includes("movie")) && listResources("movies"), + (!types || types.includes("series")) && listResources("series"), + (!types || types.includes("article")) && listResources("articles"), + (!types || types.includes("recipe")) && listResources("recipes"), ])).flat().filter(isResource); const results: Record = {}; @@ -68,27 +64,35 @@ export async function searchResource( for (const resource of resources) { if ( !(resource.name in results) && - tags?.length && resource.tags.length && - tags.every((t) => resource.tags.includes(t)) + tags?.length && resource.content.keywords?.length && + tags.every((t) => resource.content.keywords?.includes(t)) ) { - results[resource.id] = resource; + results[resource.name] = resource; } if ( - !(resource.id in results) && - rating && resource?.meta?.rating && resource.meta.rating >= rating + !(resource.name in results) && + rating && resource?.content?.reviewRating && + resource.content?.reviewRating?.ratingValue >= rating ) { - results[resource.id] = resource; + results[resource.name] = resource; } } if (q.length && q !== "*") { const fuzzyResult = fuzzysort.go(q, resources, { - keys: ["content", "name", "description", "meta.author"], + keys: [ + "name", + "content.articleBody", + "content.reviewBody", + "content.name", + "content.description", + "content.author.name", + ], threshold: 0.3, }); for (const result of fuzzyResult) { - results[result.obj.id] = result.obj; + results[result.obj.name] = result.obj; } } diff --git a/lib/taskManager.ts b/lib/taskManager.ts index 3182da4..c58878f 100644 --- a/lib/taskManager.ts +++ b/lib/taskManager.ts @@ -1,5 +1,5 @@ import { transcribe } from "@lib/openai.ts"; -import { createDocument } from "@lib/documents.ts"; +import { createResource } from "@lib/marka/index.ts"; import { createLogger } from "./log/index.ts"; import { convertOggToMp3 } from "./helpers.ts"; @@ -48,9 +48,8 @@ export async function endTask(chatId: string): Promise { finalNote += "**[Voice message could not be transcribed]**\n\n"; } } else if (entry.type === "photo") { - const photoUrl = `${ - task.noteName.replace(/\.md$/, "") - }/photo-${photoIndex++}.jpg`; + const photoUrl = `${task.noteName.replace(/\.md$/, "") + }/photo-${photoIndex++}.jpg`; finalNote += `**Photo**:\n ${photoUrl}\n\n`; photoTasks.push({ @@ -62,13 +61,13 @@ export async function endTask(chatId: string): Promise { try { for (const entry of photoTasks) { - await createDocument(entry.path, entry.content, "image/jpeg"); + await createResource(entry.path, entry.content); } } catch (err) { log.error("Error creating photo document:", err); } try { - await createDocument(task.noteName, finalNote, "text/markdown"); + await createResource(task.noteName, finalNote); } catch (error) { log.error("Error creating document:", error); return error instanceof Error diff --git a/lib/types.ts b/lib/types.ts index 97ac3dd..4597993 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,4 @@ -import { resources } from "@lib/resources.ts"; +import { GenericResource, GenericResourceSchema } from "./marka/schema.ts"; export interface TMDBMovie { adult: boolean; @@ -33,22 +33,6 @@ export interface TMDBSeries { vote_count: number; } -export type GenericResource = { - name: string; - id: string; - tags?: string[]; - type: keyof typeof resources; - content?: string; - meta?: { - image?: string; - author?: string; - rating?: number; - average?: string; - date?: Date | string; - thumbnail?: string; - }; -}; - export interface GiteaOauthUser { sub: string; name: string; @@ -61,7 +45,7 @@ export interface GiteaOauthUser { export type SearchResult = { id: string; name: string; - type: keyof typeof resources; + type: GenericResource["content"]["_type"]; date?: string; rating: number; tags: string[]; diff --git a/routes/_app.tsx b/routes/_app.tsx index 4c24222..af7eac4 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -29,7 +29,7 @@ export default function App({ Component }: PageProps) { -