Compare commits

..

3 Commits

Author SHA1 Message Date
8ebfa9c5c2 chore: update fresh.gen.ts 2026-02-10 18:20:20 +01:00
e0bfbdd719 feat: integrate Hardcover API for books
- Add lib/hardcover.ts with GraphQL client for Hardcover API
- Add routes/api/books/[name].ts for creating books via Hardcover ID
- Add routes/api/books/enhance/[name].ts for enhancing books
- Add routes/api/hardcover/query.ts for searching books
- Add routes/books/[name].tsx and index.tsx for book pages
2026-02-10 18:19:10 +01:00
c232794cc0 feat: add Book type to schema and sidebar
- Add BookContentSchema with headline, subtitle, bookBody, reviewBody fields
- Add BookResource type and update GenericResourceSchema
- Add books to sidebar navigation with Bookmark Tabs icon
2026-02-10 18:17:32 +01:00
144 changed files with 3110 additions and 4183 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,4 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
@@ -9,4 +10,3 @@ data-dev/
_fresh/
node_modules/
mise.toml

View File

@@ -1,4 +1,4 @@
FROM denoland/deno:2.6.4 AS build
FROM denoland/deno:2.5.4 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ffmpeg && \
@@ -15,11 +15,11 @@ COPY . .
ENV DATA_DIR=/app/data
RUN mkdir -p $DATA_DIR && \
deno install --allow-import --allow-ffi -e main.ts &&\
deno install --allow-import --allow-ffi --allow-scripts=npm:sharp -e main.ts &&\
sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json &&\
deno install npm:@libsql/linux-x64-gnu &&\
deno task build
EXPOSE 8000
CMD ["task", "start"]
CMD ["run", "-A", "main.ts"]

View File

@@ -5,13 +5,18 @@ Started" guide here: https://fresh.deno.dev/docs/getting-started
### Usage
Make sure to install Deno:
https://docs.deno.com/runtime/getting_started/installation
Make sure to install Deno: https://deno.land/manual/getting_started/installation
Then start the project in development mode:
Then start the project:
```
deno task dev
deno task start
```
This will watch the project directory and restart as necessary.
## FIX
```
sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json
```

View File

@@ -1 +0,0 @@
@import "tailwindcss";

View File

@@ -1,2 +0,0 @@
// Import CSS files here for hot module reloading to work.
import "./assets/styles.css";

View File

@@ -1,10 +1,12 @@
import { ButtonHTMLAttributes } from "preact";
import { JSX } from "preact";
import { IS_BROWSER } from "$fresh/runtime.ts";
export function Button(props: ButtonHTMLAttributes<HTMLButtonElement>) {
export function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
class={`cursor-pointer px-2 py-1 ${props.class ? props.class : ""}`}
disabled={!IS_BROWSER || props.disabled}
class={`px-2 py-1 ${props.class ? props.class : " "}`}
/>
);
}

View File

@@ -1,6 +1,7 @@
import { isYoutubeLink } from "@lib/string.ts";
import { IconBrandYoutube } from "@components/icons.tsx";
import { SmallRating } from "@components/Rating.tsx";
import { Link } from "@islands/Link.tsx";
import { parseRating } from "@lib/helpers.ts";
import { GenericResource, getNameOfResource } from "@lib/marka/schema.ts";
@@ -23,7 +24,7 @@ export function Card(
rating?: number;
},
) {
const backgroundStyle: preact.CSSProperties = {
const backgroundStyle: preact.JSX.CSSProperties = {
backgroundSize: "cover",
backgroundColor: backgroundColor,
};
@@ -35,7 +36,7 @@ export function Card(
}
return (
<a
<Link
href={link}
style={backgroundStyle}
data-thumb={thumbhash}
@@ -87,7 +88,7 @@ export function Card(
)}
</div>
<div class="absolute inset-x-0 bottom-0 h-3/4" />
</a>
</Link>
);
}

View File

@@ -1,5 +1,67 @@
import { Signal, useSignal } from "@preact/signals";
import { useId } from "preact/hooks";
import { useId, useState } from "preact/hooks";
interface CheckboxProps {
label: string;
isChecked?: boolean;
onChange: (isChecked: boolean) => void;
}
const Checkbox2: preact.FunctionalComponent<CheckboxProps> = (
{ label, isChecked = false, onChange },
) => {
const [checked, setChecked] = useState(isChecked);
const toggleCheckbox = () => {
const newChecked = !checked;
setChecked(newChecked);
onChange(newChecked);
};
return (
<div
class="flex items-center rounded-xl p-1 pl-4"
style={{ background: "var(--background)", color: "var(--foreground)" }}
>
<span>
{label}
</span>
<label
class="relative flex cursor-pointer items-center rounded-full p-3"
for="checkbox"
data-ripple-dark="true"
>
<input
type="checkbox"
class="before:content[''] peer relative h-5 w-5 cursor-pointer appearance-none rounded-md border border-blue-gray-200 transition-all before:absolute before:top-2/4 before:left-2/4 before:block before:h-12 before:w-12 before:-translate-y-2/4 before:-translate-x-2/4 before:rounded-full before:bg-blue-gray-500 before:opacity-0 before:transition-opacity hover:before:opacity-10"
id="checkbox"
checked
/>
<div
class={`pointer-events-none absolute top-2/4 left-2/4 -translate-y-2/4 -translate-x-2/4 text-white opacity-${
checked ? 100 : 0
} transition-opacity`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="white"
stroke="currentColor"
stroke-width="1"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
>
</path>
</svg>
</div>
</label>
</div>
);
};
const Checkbox = (
{ label, checked = useSignal(false) }: {

View File

@@ -1,4 +1,4 @@
import { asset } from "fresh/runtime";
import { asset } from "$fresh/runtime.ts";
export const Emoji = (props: { class?: string; name: string }) => {
return props.name

View File

@@ -1,4 +1,5 @@
import { asset } from "fresh/runtime";
import { asset } from "$fresh/runtime.ts";
import * as CSS from "csstype";
interface ResponsiveAttributes {
srcset: string;
@@ -37,7 +38,7 @@ const Image = (
fill?: boolean;
width?: number | string;
height?: number | string;
style?: preact.CSSProperties;
style?: CSS.HtmlAttributes;
},
) => {
const responsiveAttributes = generateResponsiveAttributes(

View File

@@ -1,4 +1,4 @@
import { Head } from "fresh/runtime";
import { Head } from "$fresh/runtime.ts";
import { GenericResource, getNameOfResource } from "@lib/marka/schema.ts";
import { formatDate } from "@lib/string.ts";
@@ -60,7 +60,6 @@ export function MetaTags({ resource }: { resource: GenericResource }) {
/>
<script
type="application/ld+json"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: jsonLd }}
/>
</Head>

View File

@@ -21,7 +21,7 @@ function Wrapper(
return (
<div
class={`flex justify-between flex-col relative w-full ${
image ? "min-h-100" : "min-h-50"
image ? "min-h-[400px]" : "min-h-[200px]"
} rounded-3xl overflow-hidden`}
>
<HeroContext.Provider value={{ image }}>
@@ -62,7 +62,7 @@ function Title(
>
{children}
{link &&
<IconExternalLink class="h-6 w-6" />}
<IconExternalLink />}
</h2>
</OuterTag>
);
@@ -110,7 +110,7 @@ function Subline(
const ctx = useContext(HeroContext);
return (
<div
class={`relative items-center z-10 flex gap-5 font-sm text-light mt-3`}
class={`relative flex items-center z-10 flex gap-5 font-sm text-light mt-3`}
style={{ color: ctx.image ? "#1F1F1F" : "white" }}
>
{children}

View File

@@ -10,9 +10,9 @@ export const Star = (
>
{Array.from({ length: max }).map((_, i) => {
if ((i + 1) <= rating) {
return <IconStarFilled key={i} class="w-4 h-4" />;
return <IconStarFilled class="w-4 h-4" />;
}
return <IconStar key={i} class="w-4 h-4" />;
return <IconStar class="w-4 h-4" />;
})}
</div>
);

View File

@@ -1,23 +1,21 @@
export {
TbAlertCircle as IconAlertCircle,
TbArrowLeft as IconArrowLeft,
TbArrowNarrowLeft as IconArrowNarrowLeft,
TbBrandYoutube as IconBrandYoutube,
TbCircleMinus as IconCircleMinus,
TbCirclePlus as IconCirclePlus,
TbEdit as IconEdit,
TbError404 as IconError404,
TbExternalLink as IconExternalLink,
TbGhost as IconGhost,
TbLoader2 as IconLoader2,
TbLogin as IconLogin,
TbLogout as IconLogout,
TbMenu2 as IconMenu2,
TbRefresh as IconRefresh,
TbReportSearch as IconReportSearch,
TbSearch as IconSearch,
TbSquareRoundedPlus as IconSquareRoundedPlus,
TbStar as IconStar,
TbStarFilled as IconStarFilled,
TbWand as IconWand,
} from "@preact-icons/tb";
export { default as IconStar } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/star.tsx";
export { default as IconStarFilled } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/star-filled.tsx";
export { default as IconExternalLink } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/external-link.tsx";
export { default as IconArrowNarrowLeft } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-narrow-left.tsx";
export { default as IconEdit } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/edit.tsx";
export { default as IconArrowLeft } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-left.tsx";
export { default as IconError404 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/error-404.tsx";
export { default as IconSquareRoundedPlus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/square-rounded-plus.tsx";
export { default as IconReportSearch } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/report-search.tsx";
export { default as IconRefresh } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/refresh.tsx";
export { default as IconCirclePlus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-plus.tsx";
export { default as IconCircleMinus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-minus.tsx";
export { default as IconLoader2 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/loader-2.tsx";
export { default as IconLogin } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/login.tsx";
export { default as IconLogout } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/logout.tsx";
export { default as IconSearch } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/search.tsx";
export { default as IconGhost } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/ghost.tsx";
export { default as IconBrandYoutube } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/brand-youtube.tsx";
export { default as IconWand } from "https://deno.land/x/tabler_icons_tsx@0.0.5/tsx/wand.tsx";
export { default as IconMenu2 } from "https://deno.land/x/tabler_icons_tsx@0.0.5/tsx/menu-2.tsx";
export { default as IconAlertCircle } from "https://deno.land/x/tabler_icons_tsx@0.0.5/tsx/alert-circle.tsx";

View File

@@ -36,5 +36,5 @@ export const MainLayout = (
);
}
return <>{children}</>;
return children;
};

View File

@@ -4,10 +4,10 @@ services:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
working_dir: /app
command: deno task dev --host 0.0.0.0
- .:/app # Mount the local directory to /app in the container
working_dir: /app # Set the working directory inside the container to /app
command: run --env-file -A --watch=static/,routes/ dev.ts # Custom start command
ports:
- "8000:8000"
- "8000:8000" # Expose the container port
environment:
- DATA_DIR=/app/data
- DATA_DIR=/app/data # Set the environment variable inside the container

115
deno.json
View File

@@ -1,99 +1,58 @@
{
"nodeModulesDir": "manual",
"lock": false,
"nodeModulesDir": "auto",
"unstable": ["cron"],
"tasks": {
"check": "deno fmt --check . && deno lint . && deno check",
"dev": "vite",
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"dev": "deno run --env-file -A --watch=static/,routes/ dev.ts",
"start": "deno run --env-file -A main.ts",
"db": "deno run --env-file -A npm:drizzle-kit",
"build": "vite build",
"start": "deno serve -A _fresh/server.js",
"update": "deno run -A -r jsr:@fresh/update ."
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
}
},
"exclude": [
"**/_fresh/*"
],
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"imports": {
"@cmd-johnson/oauth2-client": "jsr:@cmd-johnson/oauth2-client@^2.0.0",
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@components": "./components",
"@components/": "./components/",
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
"@denosaurs/emoji": "jsr:@denosaurs/emoji@^0.3.1",
"@islands": "./islands",
"@islands/": "./islands/",
"@lib": "./lib",
"@lib/": "./lib/",
"@/": "./",
"@libsql/client": "npm:@libsql/client@^0.17.0",
"@libsql/linux-x64-gnu": "npm:@libsql/linux-x64-gnu@^0.5.22",
"@openai/openai": "jsr:@openai/openai@^6.16.0",
"@preact-icons/tb": "jsr:@preact-icons/tb@^1.0.14",
"@std/http": "jsr:@std/http@^1.0.23",
"@std/media-types": "jsr:@std/media-types@^1.1.0",
"@zaubrik/djwt": "jsr:@zaubrik/djwt@^3.0.2",
"@libsql/client": "npm:@libsql/client@^0.14.0",
"@openai/openai": "jsr:@openai/openai@^6.7.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@std/http": "jsr:@std/http@^1.0.12",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"csstype": "npm:csstype@^3.1.3",
"defuddle": "npm:defuddle@^0.6.6",
"drizzle-kit": "npm:drizzle-kit@^0.31.8",
"drizzle-orm": "npm:drizzle-orm@^0.45.1",
"fresh": "jsr:@fresh/core@^2.2.0",
"drizzle-kit": "npm:drizzle-kit@^0.30.1",
"drizzle-orm": "npm:drizzle-orm@^0.38.3",
"fuzzysort": "npm:fuzzysort@^3.1.0",
"gfm": "jsr:@deno/gfm@0.11.0",
"jsdom": "npm:jsdom@^27.4.0",
"jsdom": "npm:jsdom@^24.1.3",
"moviedb-promise": "npm:moviedb-promise@^4.0.7",
"parse-ingredient": "npm:parse-ingredient@^1.3.1",
"playwright": "npm:playwright@^1.49.1",
"playwright-extra": "npm:playwright-extra@^4.3.6",
"preact": "npm:preact@^10.27.2",
"@preact/signals": "npm:@preact/signals@^2.5.0",
"@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",
"preact": "https://esm.sh/preact@10.22.0",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2",
"preact/": "https://esm.sh/preact@10.22.0/",
"gfm": "jsr:@deno/gfm",
"puppeteer-extra-plugin-stealth": "npm:puppeteer-extra-plugin-stealth@^2.11.2",
"sharp": "npm:sharp@^0.34.5",
"tailwindcss": "npm:tailwindcss@^3.4.17",
"tailwindcss/": "npm:/tailwindcss@^3.4.17/",
"tailwindcss/plugin": "npm:/tailwindcss@^3.4.17/plugin.js",
"camelcase-css": "npm:camelcase-css",
"thumbhash": "npm:thumbhash@^0.1.1",
"tsx": "npm:tsx@^4.19.2",
"turndown": "npm:turndown@^7.2.2",
"vite": "npm:vite@^7.1.3",
"tailwindcss": "npm:tailwindcss@^4.1.10",
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.12",
"zod": "npm:zod@^4.3.5"
"yaml": "https://deno.land/std@0.197.0/yaml/mod.ts",
"zod": "npm:zod@^3.24.1",
"fs": "https://deno.land/std/fs/mod.ts"
},
"compilerOptions": {
"lib": [
"dom",
"dom.asynciterable",
"dom.iterable",
"deno.ns"
],
"jsx": "precompile",
"jsxImportSource": "preact",
"jsxPrecompileSkipElements": [
"a",
"img",
"source",
"body",
"html",
"head",
"title",
"meta",
"script",
"link",
"style",
"base",
"noscript",
"template"
],
"types": [
"vite/client"
]
},
"allowScripts": {
"allow": [
"npm:sharp@0.34.5",
"npm:esbuild@0.27.2",
"npm:esbuild@0.18.20",
"npm:esbuild@0.25.12",
"npm:esbuild@0.25.7"
]
}
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"exclude": ["**/_fresh/*"]
}

2912
deno.lock generated

File diff suppressed because it is too large Load Diff

8
dev.ts Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
await dev(import.meta.url, "./main.ts", config);
Deno.exit(0);

View File

@@ -2,21 +2,19 @@ CREATE TABLE `performance` (
`path` text NOT NULL,
`search` text,
`time` integer NOT NULL,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP)
`created_at` integer DEFAULT (current_timestamp)
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`created_at` integer DEFAULT (current_timestamp),
`expires_at` integer NOT NULL,
`user_id` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`created_at` integer DEFAULT (current_timestamp) NOT NULL,
`email` text NOT NULL,
`name` text NOT NULL
);

View File

@@ -1,4 +1 @@
ALTER TABLE
`performance`
ALTER COLUMN
"created_at" TO "created_at" integer DEFAULT (STRFTIME('%s', 'now') * 1000);
ALTER TABLE `performance` ALTER COLUMN "created_at" TO "created_at" integer DEFAULT (STRFTIME('%s', 'now') * 1000);

View File

@@ -1,5 +1,5 @@
CREATE TABLE `image` (
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`created_at` integer DEFAULT (current_timestamp),
`url` text NOT NULL,
`average` text NOT NULL,
`blurhash` text NOT NULL,

View File

@@ -1,6 +1,4 @@
PRAGMA foreign_keys = OFF;
--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_document` (
`name` text PRIMARY KEY NOT NULL,
`last_modified` integer NOT NULL,
@@ -8,31 +6,8 @@ CREATE TABLE `__new_document` (
`size` integer NOT NULL,
`perm` text NOT NULL
);
--> statement-breakpoint
INSERT INTO
`__new_document`(
"name",
"last_modified",
"contentType",
"size",
"perm"
)
SELECT
"name",
"last_modified",
"contentType",
"size",
"perm"
FROM
`document`;
--> statement-breakpoint
DROP TABLE `document`;
--> statement-breakpoint
ALTER TABLE
`__new_document` RENAME TO `document`;
--> statement-breakpoint
PRAGMA foreign_keys = ON;
INSERT INTO `__new_document`("name", "last_modified", "contentType", "size", "perm") SELECT "name", "last_modified", "contentType", "size", "perm" FROM `document`;--> statement-breakpoint
DROP TABLE `document`;--> statement-breakpoint
ALTER TABLE `__new_document` RENAME TO `document`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1 @@
ALTER TABLE
`document` RENAME COLUMN "contentType" TO "content_type";
ALTER TABLE `document` RENAME COLUMN "contentType" TO "content_type";

View File

@@ -1,4 +1 @@
ALTER TABLE
`document`
ADD
`content` text;
ALTER TABLE `document` ADD `content` text;

View File

@@ -1,4 +1 @@
ALTER TABLE
`document`
ALTER COLUMN
"content" TO "content" text NOT NULL;
ALTER TABLE `document` ALTER COLUMN "content" TO "content" text NOT NULL;

View File

@@ -1,4 +1 @@
ALTER TABLE
`document`
ALTER COLUMN
"content" TO "content" text;
ALTER TABLE `document` ALTER COLUMN "content" TO "content" text;

View File

@@ -3,15 +3,10 @@ CREATE TABLE `cache` (
`key` text PRIMARY KEY NOT NULL,
`json` text,
`binary` blob,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`created_at` integer DEFAULT (current_timestamp),
`expires_at` integer
);
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);
--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);--> statement-breakpoint
CREATE INDEX `name_idx` ON `document` (`name`);

View File

@@ -1,2 +1 @@
ALTER TABLE
`image` RENAME COLUMN "blurhash" TO "thumbhash";
ALTER TABLE `image` RENAME COLUMN "blurhash" TO "thumbhash";

View File

@@ -1,22 +0,0 @@
DROP INDEX "key_idx";
--> statement-breakpoint
DROP INDEX "scope_idx";
--> statement-breakpoint
DROP INDEX "name_idx";
--> statement-breakpoint
ALTER TABLE
`image`
ALTER COLUMN
"created_at" TO "created_at" integer DEFAULT (unixepoch());
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);
--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);
--> statement-breakpoint
CREATE INDEX `name_idx` ON `document` (`name`);

View File

@@ -1,309 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "11bbbc9d-3c0c-4fb9-893f-c87d5b8660d0",
"prevId": "685b57ca-45e0-4373-baee-fc3abb4f2d74",
"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": "(unixepoch())"
},
"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": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -78,13 +78,6 @@
"when": 1762099260474,
"tag": "0010_youthful_tyrannus",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1768058169808,
"tag": "0011_reflective_frank_castle",
"breakpoints": true
}
]
}

5
fresh.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from "$fresh/server.ts";
import tailwind from "$fresh/plugins/tailwind.ts";
export default defineConfig({
plugins: [tailwind()],
});

164
fresh.gen.ts Normal file
View File

@@ -0,0 +1,164 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_layout from "./routes/_layout.tsx";
import * as $_middleware from "./routes/_middleware.ts";
import * as $admin_cache_index from "./routes/admin/cache/index.tsx";
import * as $admin_log_index from "./routes/admin/log/index.tsx";
import * as $admin_performance_index from "./routes/admin/performance/index.tsx";
import * as $api_articles_name_ from "./routes/api/articles/[name].ts";
import * as $api_articles_create_index from "./routes/api/articles/create/index.ts";
import * as $api_articles_enhance_name_ from "./routes/api/articles/enhance/[name].ts";
import * as $api_articles_index from "./routes/api/articles/index.ts";
import * as $api_auth_callback from "./routes/api/auth/callback.ts";
import * as $api_auth_login from "./routes/api/auth/login.ts";
import * as $api_auth_logout from "./routes/api/auth/logout.ts";
import * as $api_books_name_ from "./routes/api/books/[name].ts";
import * as $api_books_create_index from "./routes/api/books/create/index.ts";
import * as $api_books_enhance_name_ from "./routes/api/books/enhance/[name].ts";
import * as $api_books_index from "./routes/api/books/index.ts";
import * as $api_cache from "./routes/api/cache.ts";
import * as $api_hardcover_query from "./routes/api/hardcover/query.ts";
import * as $api_images_index from "./routes/api/images/index.ts";
import * as $api_index from "./routes/api/index.ts";
import * as $api_logs from "./routes/api/logs.ts";
import * as $api_movies_name_ from "./routes/api/movies/[name].ts";
import * as $api_movies_enhance_name_ from "./routes/api/movies/enhance/[name].ts";
import * as $api_movies_index from "./routes/api/movies/index.ts";
import * as $api_query_index from "./routes/api/query/index.ts";
import * as $api_recipes_name_ from "./routes/api/recipes/[name].ts";
import * as $api_recipes_create_index from "./routes/api/recipes/create/index.ts";
import * as $api_recipes_create_parseJsonLd from "./routes/api/recipes/create/parseJsonLd.ts";
import * as $api_recipes_index from "./routes/api/recipes/index.ts";
import * as $api_recommendation_all from "./routes/api/recommendation/all.ts";
import * as $api_recommendation_data from "./routes/api/recommendation/data.ts";
import * as $api_recommendation_index from "./routes/api/recommendation/index.ts";
import * as $api_recommendation_movie_id_ from "./routes/api/recommendation/movie/[id].ts";
import * as $api_series_name_ from "./routes/api/series/[name].ts";
import * as $api_series_enhance_name_ from "./routes/api/series/enhance/[name].ts";
import * as $api_series_index from "./routes/api/series/index.ts";
import * as $api_tmdb_id_ from "./routes/api/tmdb/[id].ts";
import * as $api_tmdb_credits_id_ from "./routes/api/tmdb/credits/[id].ts";
import * as $api_tmdb_query from "./routes/api/tmdb/query.ts";
import * as $articles_name_ from "./routes/articles/[name].tsx";
import * as $articles_index from "./routes/articles/index.tsx";
import * as $books_name_ from "./routes/books/[name].tsx";
import * as $books_index from "./routes/books/index.tsx";
import * as $index from "./routes/index.tsx";
import * as $movies_name_ from "./routes/movies/[name].tsx";
import * as $movies_index from "./routes/movies/index.tsx";
import * as $recipes_name_ from "./routes/recipes/[name].tsx";
import * as $recipes_index from "./routes/recipes/index.tsx";
import * as $series_name_ from "./routes/series/[name].tsx";
import * as $series_index from "./routes/series/index.tsx";
import * as $Counter from "./islands/Counter.tsx";
import * as $IngredientsList from "./islands/IngredientsList.tsx";
import * as $KMenu from "./islands/KMenu.tsx";
import * as $KMenu_commands from "./islands/KMenu/commands.ts";
import * as $KMenu_commands_add_movie_infos from "./islands/KMenu/commands/add_movie_infos.ts";
import * as $KMenu_commands_add_series_infos from "./islands/KMenu/commands/add_series_infos.ts";
import * as $KMenu_commands_create_article from "./islands/KMenu/commands/create_article.ts";
import * as $KMenu_commands_create_book from "./islands/KMenu/commands/create_book.ts";
import * as $KMenu_commands_create_movie from "./islands/KMenu/commands/create_movie.ts";
import * as $KMenu_commands_create_recipe from "./islands/KMenu/commands/create_recipe.ts";
import * as $KMenu_commands_create_recommendations from "./islands/KMenu/commands/create_recommendations.ts";
import * as $KMenu_commands_create_series from "./islands/KMenu/commands/create_series.ts";
import * as $KMenu_commands_enhance_article_infos from "./islands/KMenu/commands/enhance_article_infos.ts";
import * as $KMenu_commands_enhance_book_infos from "./islands/KMenu/commands/enhance_book_infos.ts";
import * as $KMenu_types from "./islands/KMenu/types.ts";
import * as $KMenuButton from "./islands/KMenuButton.tsx";
import * as $Link from "./islands/Link.tsx";
import * as $Recommendations from "./islands/Recommendations.tsx";
import * as $Search from "./islands/Search.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/_layout.tsx": $_layout,
"./routes/_middleware.ts": $_middleware,
"./routes/admin/cache/index.tsx": $admin_cache_index,
"./routes/admin/log/index.tsx": $admin_log_index,
"./routes/admin/performance/index.tsx": $admin_performance_index,
"./routes/api/articles/[name].ts": $api_articles_name_,
"./routes/api/articles/create/index.ts": $api_articles_create_index,
"./routes/api/articles/enhance/[name].ts": $api_articles_enhance_name_,
"./routes/api/articles/index.ts": $api_articles_index,
"./routes/api/auth/callback.ts": $api_auth_callback,
"./routes/api/auth/login.ts": $api_auth_login,
"./routes/api/auth/logout.ts": $api_auth_logout,
"./routes/api/books/[name].ts": $api_books_name_,
"./routes/api/books/create/index.ts": $api_books_create_index,
"./routes/api/books/enhance/[name].ts": $api_books_enhance_name_,
"./routes/api/books/index.ts": $api_books_index,
"./routes/api/cache.ts": $api_cache,
"./routes/api/hardcover/query.ts": $api_hardcover_query,
"./routes/api/images/index.ts": $api_images_index,
"./routes/api/index.ts": $api_index,
"./routes/api/logs.ts": $api_logs,
"./routes/api/movies/[name].ts": $api_movies_name_,
"./routes/api/movies/enhance/[name].ts": $api_movies_enhance_name_,
"./routes/api/movies/index.ts": $api_movies_index,
"./routes/api/query/index.ts": $api_query_index,
"./routes/api/recipes/[name].ts": $api_recipes_name_,
"./routes/api/recipes/create/index.ts": $api_recipes_create_index,
"./routes/api/recipes/create/parseJsonLd.ts":
$api_recipes_create_parseJsonLd,
"./routes/api/recipes/index.ts": $api_recipes_index,
"./routes/api/recommendation/all.ts": $api_recommendation_all,
"./routes/api/recommendation/data.ts": $api_recommendation_data,
"./routes/api/recommendation/index.ts": $api_recommendation_index,
"./routes/api/recommendation/movie/[id].ts": $api_recommendation_movie_id_,
"./routes/api/series/[name].ts": $api_series_name_,
"./routes/api/series/enhance/[name].ts": $api_series_enhance_name_,
"./routes/api/series/index.ts": $api_series_index,
"./routes/api/tmdb/[id].ts": $api_tmdb_id_,
"./routes/api/tmdb/credits/[id].ts": $api_tmdb_credits_id_,
"./routes/api/tmdb/query.ts": $api_tmdb_query,
"./routes/articles/[name].tsx": $articles_name_,
"./routes/articles/index.tsx": $articles_index,
"./routes/books/[name].tsx": $books_name_,
"./routes/books/index.tsx": $books_index,
"./routes/index.tsx": $index,
"./routes/movies/[name].tsx": $movies_name_,
"./routes/movies/index.tsx": $movies_index,
"./routes/recipes/[name].tsx": $recipes_name_,
"./routes/recipes/index.tsx": $recipes_index,
"./routes/series/[name].tsx": $series_name_,
"./routes/series/index.tsx": $series_index,
},
islands: {
"./islands/Counter.tsx": $Counter,
"./islands/IngredientsList.tsx": $IngredientsList,
"./islands/KMenu.tsx": $KMenu,
"./islands/KMenu/commands.ts": $KMenu_commands,
"./islands/KMenu/commands/add_movie_infos.ts":
$KMenu_commands_add_movie_infos,
"./islands/KMenu/commands/add_series_infos.ts":
$KMenu_commands_add_series_infos,
"./islands/KMenu/commands/create_article.ts":
$KMenu_commands_create_article,
"./islands/KMenu/commands/create_book.ts": $KMenu_commands_create_book,
"./islands/KMenu/commands/create_movie.ts": $KMenu_commands_create_movie,
"./islands/KMenu/commands/create_recipe.ts": $KMenu_commands_create_recipe,
"./islands/KMenu/commands/create_recommendations.ts":
$KMenu_commands_create_recommendations,
"./islands/KMenu/commands/create_series.ts": $KMenu_commands_create_series,
"./islands/KMenu/commands/enhance_article_infos.ts":
$KMenu_commands_enhance_article_infos,
"./islands/KMenu/commands/enhance_book_infos.ts":
$KMenu_commands_enhance_book_infos,
"./islands/KMenu/types.ts": $KMenu_types,
"./islands/KMenuButton.tsx": $KMenuButton,
"./islands/Link.tsx": $Link,
"./islands/Recommendations.tsx": $Recommendations,
"./islands/Search.tsx": $Search,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

View File

@@ -1,6 +1,6 @@
import type { Signal } from "@preact/signals";
import { Button } from "@components/Button.tsx";
import { TbCircleMinus, TbCirclePlus } from "@preact-icons/tb";
import { IconCircleMinus, IconCirclePlus } from "@components/icons.tsx";
interface CounterProps {
count: Signal<number>;
@@ -10,21 +10,21 @@ export default function Counter(props: CounterProps) {
props.count.value = Math.max(1, props.count.value);
return (
<div class="flex items-center px-1 py-2 rounded-xl">
<Button onClick={() => props.count.value -= 1}>
<TbCircleMinus class="h-6 w-6" />
<Button
class=""
onClick={() => props.count.value -= 1}
>
<IconCircleMinus />
</Button>
<input
class="text-3xl bg-transparent inline text-center -mx-4"
type="number"
size={props.count.toString().length}
value={props.count}
onInput={(ev) => {
const target = ev.target as HTMLInputElement;
props.count.value = Math.max(1, Number(target.value));
}}
onInput={(ev) => props.count.value = ev.target?.value}
/>
<Button onClick={() => props.count.value += 1}>
<TbCirclePlus class="h-6 w-6" />
<IconCirclePlus />
</Button>
</div>
);

View File

@@ -1,5 +1,6 @@
import { Signal } from "@preact/signals";
import type { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { FunctionalComponent } from "preact";
import { unitsOfMeasure } from "@lib/parseIngredient.ts";
function formatAmount(num: number) {
@@ -49,12 +50,14 @@ const Ingredient = (
);
};
export const IngredientsList = (
{ ingredients, amount, portion }: {
export const IngredientsList: FunctionalComponent<
{
ingredients: (Ingredient | IngredientGroup)[];
amount: Signal<number>;
portion?: number;
},
}
> = (
{ ingredients, amount, portion },
) => {
return (
<table class="w-full border-collapse table-auto">

View File

@@ -4,6 +4,7 @@ import { useEventListener } from "@lib/hooks/useEventListener.ts";
import { menus } from "@islands/KMenu/commands.ts";
import { MenuEntry } from "@islands/KMenu/types.ts";
import * as icons from "@components/icons.tsx";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { isKMenuOpen } from "@lib/kmenu.ts";
const KMenuEntry = (
{ entry, activeIndex, index }: {
@@ -154,17 +155,17 @@ export const KMenu = (
} else {
input.current?.blur();
}
}, typeof document !== "undefined" ? document?.body : undefined);
}, IS_BROWSER ? document?.body : undefined);
return (
<div
class={`${visible.value ? "opacity-100" : "opacity-0"} ${
visible.value ? "pointer-events-auto" : "pointer-events-none"
class={`${visible.value ? "opacity-100" : "opacity-0"} pointer-events-${
visible.value ? "auto" : "none"
} transition grid place-items-center w-full h-full fixed top-0 left-0 z-50`}
style={{ background: "#141217ee" }}
>
<div
class={`relative w-1/2 max-h-64 max-w-100 rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 bg-gray-700`}
class={`relative w-1/2 max-h-64 max-w-[400px] rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 bg-gray-700`}
style={{ background: "#2B2930", color: "#818181" }}
>
<div

View File

@@ -5,8 +5,11 @@ import { createNewArticle } from "@islands/KMenu/commands/create_article.ts";
import { getCookie } from "@lib/string.ts";
import { addSeriesInfo } from "@islands/KMenu/commands/add_series_infos.ts";
import { createNewSeries } from "@islands/KMenu/commands/create_series.ts";
import { updateAllRecommendations } from "@islands/KMenu/commands/create_recommendations.ts";
import { createNewRecipe } from "@islands/KMenu/commands/create_recipe.ts";
import { enhanceArticleInfo } from "@islands/KMenu/commands/enhance_article_infos.ts";
import { createNewBook } from "@islands/KMenu/commands/create_book.ts";
import { enhanceBookInfo } from "@islands/KMenu/commands/enhance_book_infos.ts";
export const menus: Record<string, Menu> = {
main: {
@@ -73,11 +76,13 @@ export const menus: Record<string, Menu> = {
},
addSeriesInfo,
createNewArticle,
createNewBook,
createNewMovie,
createNewSeries,
createNewRecipe,
addMovieInfos,
enhanceArticleInfo,
enhanceBookInfo,
// updateAllRecommendations,
],
},

View File

@@ -42,12 +42,8 @@ export const addMovieInfos: MenuEntry = {
globalThis.location.reload();
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
})),
};
@@ -57,12 +53,8 @@ export const addMovieInfos: MenuEntry = {
state.activeState.value = "normal";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
visible: () => {
const loc = globalThis["location"];

View File

@@ -42,12 +42,8 @@ export const addSeriesInfo: MenuEntry = {
//window.location.reload();
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
})),
};
@@ -57,12 +53,8 @@ export const addSeriesInfo: MenuEntry = {
state.activeState.value = "normal";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
visible: () => {
const loc = globalThis["location"];

View File

@@ -0,0 +1,134 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { debounce } from "@lib/helpers.ts";
import { getCookie } from "@lib/string.ts";
import { BookResource } from "@lib/marka/schema.ts";
interface HardcoverBook {
id: string;
slug: string;
title: string;
subtitle?: string;
author_names: string[];
series_names?: string[];
release_year?: string;
image?: string;
}
export const createNewBook: MenuEntry = {
title: "Create new book",
meta: "",
icon: "IconSquareRoundedPlus",
cb: (state) => {
state.menus["input_book"] = {
title: "Search",
entries: [],
};
state.menus["loading"] = {
title: "Search",
entries: [
{
title: "Loading",
icon: "IconLoader2",
cb() {},
},
],
};
state.activeMenu.value = "input_book";
state.activeState.value = "normal";
let currentQuery: string;
const search = debounce(async function search(query: string) {
try {
currentQuery = query;
if (query.length < 2) {
state.menus["input_book"] = {
title: "Search",
entries: [
{
title: "Type at least 2 characters...",
cb: () => {},
},
],
};
state.activeMenu.value = "input_book";
return;
}
const response = await fetch("/api/hardcover/query?q=" + encodeURIComponent(query));
if (!response.ok) {
throw new Error(await response.text());
}
const books = await response.json() as HardcoverBook[];
if (query !== currentQuery) return;
if (books.length === 0) {
state.menus["input_book"] = {
title: "Search",
entries: [
{
title: "No results found",
cb: () => {},
},
],
};
} else {
state.menus["input_book"] = {
title: "Search",
entries: books.map((b) => {
return {
title: `${b.title}${b.release_year ? ` (${b.release_year})` : ""}${b.author_names?.length ? ` - ${b.author_names.join(", ")}` : ""}`,
cb: async () => {
try {
state.activeState.value = "loading";
const response = await fetch("/api/books/" + b.id, {
method: "POST",
});
if (!response.ok) {
throw new Error(await response.text());
}
const book = await response.json() as BookResource;
unsub();
globalThis.location.href = "/books/" + book.name;
} catch (_e: unknown) {
state.activeState.value = "error";
state.loadingText.value = _e instanceof Error ? _e.message : "Unknown error";
}
},
};
}),
};
}
state.activeMenu.value = "input_book";
} catch (_e: unknown) {
state.activeState.value = "error";
state.loadingText.value = _e instanceof Error ? _e.message : "Unknown error";
}
}, 500);
const unsub = state.commandInput.subscribe((value) => {
if (!value) {
state.menus["input_book"] = {
title: "Search",
entries: [],
};
state.activeMenu.value = "input_book";
return;
}
state.activeMenu.value = "loading";
search(value);
});
},
visible: () => {
if (!getCookie("session_cookie")) return false;
if (
!globalThis?.location?.pathname?.includes("book") &&
globalThis?.location?.pathname !== "/"
) return false;
return true;
},
};

View File

@@ -66,12 +66,8 @@ export const createNewMovie: MenuEntry = {
globalThis.location.href = "/movies/" + movie.name;
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
};
}),
@@ -79,12 +75,8 @@ export const createNewMovie: MenuEntry = {
state.activeMenu.value = "input_link";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
}, 500);
const unsub = state.commandInput.subscribe((value) => {

View File

@@ -68,12 +68,8 @@ export const createNewSeries: MenuEntry = {
globalThis.location.href = "/series/" + series.name;
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
};
}),
@@ -82,12 +78,8 @@ export const createNewSeries: MenuEntry = {
state.activeMenu.value = "input_link";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
}, 500);
const unsub = state.commandInput.subscribe((value) => {

View File

@@ -0,0 +1,41 @@
import { getCookie } from "@lib/string.ts";
import { MenuEntry } from "../types.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { fetchStream } from "@lib/helpers.ts";
export const enhanceBookInfo: MenuEntry = {
title: "Enhance Book Info",
meta: "Update metadata and content from Hardcover",
icon: "IconWand",
cb: (state, context) => {
state.activeState.value = "loading";
const book = context as BookResource;
fetchStream(
`/api/books/enhance/${book.name}/`,
(chunk) => {
if (chunk.type === "error") {
state.activeState.value = "error";
state.loadingText.value = chunk.message;
} else if (chunk.type == "finished") {
state.loadingText.value = "Finished";
setTimeout(() => {
state.visible.value = false;
state.activeState.value = "normal";
globalThis.location.reload();
}, 500);
} else {
state.loadingText.value = chunk.message;
}
},
{ method: "POST" },
);
},
visible: () => {
const loc = globalThis["location"];
if (!getCookie("session_cookie")) return false;
return (loc?.pathname?.includes("book") &&
!loc.pathname.endsWith("books"));
},
};

View File

@@ -8,7 +8,7 @@ export function Link(
props: {
href?: string;
class?: string;
style?: preact.CSSProperties;
style?: preact.JSX.CSSProperties;
children: preact.ComponentChildren;
"data-thumb"?: string;
},

View File

@@ -4,7 +4,7 @@ import { IconLoader2, IconSearch } from "@components/icons.tsx";
import { useEventListener } from "@lib/hooks/useEventListener.ts";
import { resources } from "@lib/resources.ts";
import { getCookie } from "@lib/string.ts";
import { IS_BROWSER } from "fresh/runtime";
import { IS_BROWSER } from "$fresh/runtime.ts";
import Checkbox from "@components/Checkbox.tsx";
import { Rating } from "@components/Rating.tsx";
import { useSignal } from "@preact/signals";

View File

@@ -1,4 +1,4 @@
import { OAuth2Client } from "@cmd-johnson/oauth2-client";
import { OAuth2Client } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts";
import {
GITEA_CLIENT_ID,
GITEA_CLIENT_SECRET,

View File

@@ -12,9 +12,7 @@ export const userTable = sqliteTable("user", {
id: text()
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp" })
.default(sql`
(CURRENT_TIMESTAMP)
`)
.default(sql`(current_timestamp)`)
.notNull(),
email: text()
.notNull(),
@@ -26,9 +24,7 @@ export const sessionTable = sqliteTable("session", {
id: text("id")
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(
sql`
(CURRENT_TIMESTAMP)
`,
sql`(current_timestamp)`,
),
expiresAt: integer("expires_at", { mode: "timestamp" })
.notNull(),
@@ -42,16 +38,12 @@ export const performanceTable = sqliteTable("performance", {
time: int().notNull(),
createdAt: integer("created_at", {
mode: "timestamp_ms",
}).default(sql`
(STRFTIME('%s', 'now') * 1000)
`),
}).default(sql`(STRFTIME('%s', 'now') * 1000)`),
});
export const imageTable = sqliteTable("image", {
createdAt: integer("created_at", { mode: "timestamp" }).default(
sql`
(unixepoch())
`,
sql`(current_timestamp)`,
),
url: text().notNull(),
average: text().notNull(),
@@ -78,9 +70,7 @@ export const cacheTable = sqliteTable("cache", {
json: text({ mode: "json" }),
binary: blob(),
createdAt: integer("created_at", { mode: "timestamp" }).default(
sql`
(CURRENT_TIMESTAMP)
`,
sql`(current_timestamp)`,
),
expiresAt: integer("expires_at", { mode: "timestamp" }),
}, (table) => {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
const DB_FILE = "file:" + path.resolve(DATA_DIR, "db.sqlite");
// You can specify any property from the libsql connection options
export const db = drizzle({
connection: {
url: DB_FILE,

View File

@@ -1,9 +1,8 @@
import { Context } from "fresh";
import { State } from "../utils.ts";
import { FreshContext } from "$fresh/server.ts";
class DomainError extends Error {
status = 500;
render?: (ctx: Context<State>) => void;
render?: (ctx: FreshContext) => void;
constructor(public statusText = "Internal Server Error") {
super();
}

140
lib/hardcover.ts Normal file
View File

@@ -0,0 +1,140 @@
import { createCache } from "@lib/cache.ts";
const HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql";
const CACHE_INTERVAL = 1000 * 60 * 60 * 24 * 7;
const cache = createCache("hardcover", { expires: CACHE_INTERVAL });
export interface HardcoverBookResult {
id: string;
slug: string;
title: string;
subtitle?: string;
author_names: string[];
cover_color?: string;
description?: string;
isbn?: string;
isbn13?: string;
release_year?: string;
series_names?: string[];
rating?: number;
ratings_count?: number;
pages?: number;
image?: string;
}
async function hardcoverFetch(query: string, variables: Record<string, unknown> = {}) {
const response = await fetch(HARDCOVER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": Deno.env.get("HARDCOVER_API_TOKEN") || "",
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`Hardcover API error: ${response.statusText}`);
}
const json = await response.json();
if (json.errors) {
console.error("Hardcover GraphQL errors:", JSON.stringify(json.errors, null, 2));
throw new Error(`Hardcover GraphQL error: ${json.errors[0]?.message || "Unknown error"}`);
}
return json;
}
export const searchBook = async (query: string) => {
const id = `query:booksearch:${query}`;
if (cache.has(id)) return cache.get(id) as HardcoverBookResult[];
const graphqlQuery = `
query SearchBooks($query: String!, $perPage: Int, $page: Int) {
search(
query: $query
query_type: "Book"
per_page: $perPage
page: $page
) {
results
}
}
`;
const result = await hardcoverFetch(graphqlQuery, {
query,
perPage: 10,
page: 1,
});
const typesenseResponse = result.data?.search?.results;
const hits = typesenseResponse?.hits;
const books = (hits || []).map((hit: { document: HardcoverBookResult }) => hit.document);
cache.set(id, books);
return books as HardcoverBookResult[];
};
export const getBookDetails = async (id: string) => {
const cacheId = `query:bookdetails:${id}`;
if (cache.has(cacheId)) return cache.get(cacheId);
const graphqlQuery = `
query GetBook($id: Int!) {
books(where: { id: { _eq: $id } }) {
id
slug
title
subtitle
description
release_date
release_year
rating
ratings_count
pages
cached_image
cached_contributors
featured_book_series {
series {
id
name
slug
}
}
editions(limit: 1) {
isbn_10
isbn_13
}
}
}
`;
const result = await hardcoverFetch(graphqlQuery, { id: parseInt(id) });
const bookData = result.data?.books?.[0];
if (bookData) {
const isbn13 = bookData.editions?.[0]?.isbn_13;
const isbn10 = bookData.editions?.[0]?.isbn_10;
const cachedContributors = bookData.cached_contributors as Array<{ author: { name: string }; contribution: string }> | undefined;
const authorName = cachedContributors?.[0]?.author?.name;
const book = {
...bookData,
isbn: isbn13 || isbn10 || "",
isbn13,
isbn10,
author_names: authorName ? [authorName] : [],
image: bookData.cached_image?.url,
};
cache.set(cacheId, book);
return book;
}
return null;
};

View File

@@ -1,13 +1,14 @@
import { rgbToHex } from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { generateThumbhash } from "@lib/thumbhash.ts";
import { parseMediaType } from "@std/media-types";
import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts";
import path from "node:path";
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";
import { eq } from "drizzle-orm";
import sharp from "npm:sharp@next";
const log = createLogger("cache/image");
@@ -157,8 +158,6 @@ async function resizeImage(
mediaType: string;
},
) {
const sharp = (await import("sharp")).default;
try {
log.debug("Resizing image", { params });
@@ -212,8 +211,6 @@ async function resizeImage(
async function createThumbhash(
image: Uint8Array,
): Promise<{ hash: string; average: string }> {
const sharp = (await import("sharp")).default;
try {
const resizedImage = await sharp(image)
.resize(100, 100, { fit: "cover" }) // Keep aspect ratio within bounds
@@ -222,11 +219,7 @@ async function createThumbhash(
.raw()
.toBuffer();
const [hash, average] = generateThumbhash(
new Uint8Array(resizedImage),
100,
100,
);
const [hash, average] = generateThumbhash(resizedImage, 100, 100);
return {
hash: btoa(String.fromCharCode(...hash)),
@@ -241,8 +234,6 @@ async function createThumbhash(
* Verifies that an image buffer contains valid image data
*/
async function verifyImage(imageBuffer: Uint8Array): Promise<boolean> {
const sharp = (await import("sharp")).default;
try {
const metadata = await sharp(imageBuffer).metadata();
return !!(metadata.width && metadata.height && metadata.format);

View File

@@ -36,10 +36,9 @@ export async function getLogs() {
.map((line) => {
const [date, ...rest] = line.split(" | ");
const parsed = JSON.parse(rest.join(" | ")) as Log;
const dateObj = new Date(date);
return {
...parsed,
date: isNaN(dateObj.getTime()) ? new Date() : dateObj,
date: new Date(date),
} as Log;
});

View File

@@ -32,7 +32,7 @@ export const BaseFileSchema = z.object({
});
const makeContentSchema = <
TName extends "Article" | "Review" | "Recipe",
TName extends "Article" | "Review" | "Recipe" | "Book",
TShape extends z.ZodRawShape,
>(
name: TName,
@@ -55,6 +55,9 @@ export const ArticleContentSchema = makeContentSchema("Article", {
export const ReviewContentSchema = makeContentSchema("Review", {
tmdbId: z.number().optional(),
headline: z.string().optional(),
subtitle: z.string().optional(),
bookBody: z.string().optional(),
link: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
reviewBody: z.string().optional(),
@@ -76,6 +79,17 @@ export const RecipeContentSchema = makeContentSchema("Recipe", {
url: z.string().optional(),
});
export const BookContentSchema = makeContentSchema("Book", {
headline: z.string().optional(),
subtitle: z.string().optional(),
bookBody: z.string().optional(),
reviewBody: z.string().optional(),
url: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
isbn: z.string().optional(),
bookEdition: 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"),
@@ -99,10 +113,15 @@ export const RecipeSchema = BaseFileSchema.extend({
content: RecipeContentSchema,
});
export const BookSchema = BaseFileSchema.extend({
content: BookContentSchema,
});
export const GenericResourceSchema = z.union([
ArticleSchema,
ReviewSchema,
RecipeSchema,
BookSchema,
]);
export type Person = z.infer<typeof PersonSchema>;
@@ -122,6 +141,10 @@ export type RecipeResource = z.infer<typeof RecipeSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type BookResource = z.infer<typeof BookSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
image?: typeof imageTable.$inferSelect;
};
@@ -136,5 +159,8 @@ export function getNameOfResource(res: GenericResource): string {
if (res.content?._type === "Recipe" && res.content.name) {
return res.content.name;
}
if (res.content?._type === "Book" && res.content.headline) {
return res.content.headline;
}
return "Unnamed Resource";
}

View File

@@ -1,7 +1,7 @@
import { render } from "gfm";
import "prismjs/components/prism-typescript.js";
import "prismjs/components/prism-bash.js";
import "prismjs/components/prism-rust.js";
import "https://esm.sh/prismjs@1.29.0/components/prism-typescript?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-bash?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-rust?no-check";
export type Document = {
name: string;

View File

@@ -8,7 +8,7 @@ import { articleMetadataSchema } from "./marka/schema.ts";
const openAI = OPENAI_API_KEY && new OpenAI({ apiKey: OPENAI_API_KEY });
export interface MovieRecommendation {
interface MovieRecommendation {
year: number;
title: string;
}
@@ -181,14 +181,14 @@ respond with a plain unordered list each item starting with the year the movie w
if (!res) return;
const recommendations = res.split("\n").map((entry: string) => {
const recommendations = res.split("\n").map((entry) => {
const [year, ...title] = entry.split("-");
return {
year: parseInt(year.trim()),
title: title.join(" ").replaceAll('"', "").trim(),
};
}).filter((y: { year: number }) => !Number.isNaN(y.year));
}).filter((y) => !Number.isNaN(y.year));
cache.set(cacheId, recommendations);

View File

@@ -1,6 +1,6 @@
import { firefox } from "playwright-extra";
import { firefox } from "npm:playwright-extra";
import { createStreamResponse } from "@lib/helpers.ts";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import StealthPlugin from "npm:puppeteer-extra-plugin-stealth";
import * as env from "@lib/env.ts";
firefox.use(StealthPlugin());

105
lib/promise.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Interface zur Beschreibung eines eingereihten Promises in der `PromiseQueue`.
*/
interface QueuedPromise<T = any> {
promise: () => Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
}
/**
* Eine einfache Promise Queue, die es ermöglicht mehrere Aufgaben in kontrollierter
* Reihenfolge abzuarbeiten.
*
* Lizenz: CC BY-NC-SA 4.0
* (c) Peter Müller <peter@crycode.de> (https://crycode.de/promise-queue-in-typescript)
*/
export class PromiseQueue {
/**
* Eingereihte Promises.
*/
private queue: QueuedPromise[] = [];
/**
* Indikator, dass aktuell ein Promise abgearbeitet wird.
*/
private working = false;
/**
* Ein Promise einreihen.
* Dies fügt das Promise der Warteschlange hinzu. Wenn die Warteschlange leer
* ist, dann wird das Promise sofort gestartet.
* @param promise Funktion, die das Promise zurückgibt.
* @returns Ein Promise, welches eingelöst (oder zurückgewiesen) wird sobald das eingereihte Promise abgearbeitet ist.
*/
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({
promise,
resolve,
reject,
});
this.dequeue();
});
}
/**
* Das erste Promise aus der Warteschlange holen und starten, sofern nicht
* bereits ein Promise aktiv ist.
* @returns `true` wenn ein Promise aus der Warteschlange gestartet wurde oder `false` wenn bereits ein Promise aktiv oder die Warteschlange leer ist.
*/
private dequeue(): boolean {
if (this.working) {
return false;
}
const item = this.queue.shift();
if (!item) {
return false;
}
try {
this.working = true;
item.promise()
.then((value) => {
item.resolve(value);
})
.catch((err) => {
item.reject(err);
})
.finally(() => {
this.working = false;
this.dequeue();
});
} catch (err) {
item.reject(err);
this.working = false;
this.dequeue();
}
return true;
}
}
export class ConcurrentPromiseQueue {
/**
* Eingereihte Promises.
*/
private queues: PromiseQueue[] = [];
constructor(concurrency: number = 1) {
this.queues = Array.from({ length: concurrency }).map(() => {
return new PromiseQueue();
});
}
private queueIndex = 0;
private getQueue() {
this.queueIndex = (this.queueIndex + 1) % this.queues.length;
return this.queues[this.queueIndex];
}
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return this.getQueue().enqueue(promise);
}
}

View File

@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "npm:zod";
import { RecipeResource } from "./marka/schema.ts";
export const IngredientSchema = z.object({

View File

@@ -1,5 +1,4 @@
import * as openai from "@lib/openai.ts";
import { type MovieRecommendation } from "@lib/openai.ts";
import * as tmdb from "@lib/tmdb.ts";
import { parseRating } from "@lib/helpers.ts";
import { createCache } from "@lib/cache.ts";
@@ -61,10 +60,8 @@ export async function createRecommendationResource(
const d = typeof datePublished === "string"
? new Date(datePublished)
: datePublished;
if (!isNaN(d.getTime())) {
resource.year = d.getFullYear();
}
}
cache.set(cacheId, JSON.stringify(resource));
}
@@ -86,12 +83,10 @@ export async function getSimilarMovies(id: string) {
);
if (!recommendations) return;
const movies = await Promise.all(
recommendations.map(async (rec: MovieRecommendation) => {
const movies = await Promise.all(recommendations.map(async (rec) => {
const m = await tmdb.searchMovie(rec.title, rec.year);
return m?.results?.[0];
}),
);
}));
return movies.filter(Boolean);
}
@@ -101,7 +96,5 @@ export async function getAllRecommendations(): Promise<
> {
const keys = cache.keys();
const res = await Promise.all(keys.map((k) => cache.get(k)));
return res.filter((s) => !!s).map((r) =>
typeof r === "string" ? JSON.parse(r) : r
);
return res.filter((s) => !!s).map((r) => JSON.parse(r));
}

View File

@@ -24,4 +24,9 @@ export const resources = {
name: "Series",
link: "/series",
},
"book": {
emoji: "Bookmark Tabs.png",
name: "Books",
link: "/books",
},
} as const;

View File

@@ -186,7 +186,3 @@ export function removeMarkdownFormatting(text: string): string {
return text;
}
export function fileExtension(fname: string) {
return fname.slice((fname.lastIndexOf(".") - 1 >>> 0) + 2);
}

110
lib/taskManager.ts Normal file
View File

@@ -0,0 +1,110 @@
import { transcribe } from "@lib/openai.ts";
import { createResource } from "@lib/marka/index.ts";
import { createLogger } from "./log/index.ts";
import { convertOggToMp3 } from "./helpers.ts";
const log = createLogger("taskManager");
// In-memory task state
const activeTasks: Record<
string,
{
noteName: string;
entries: Array<
{ type: string; content: string | ArrayBufferLike; fileName?: string }
>;
}
> = {};
export function startTask(chatId: string, noteName: string) {
activeTasks[chatId] = { noteName, entries: [] };
log.info(`Started note: ${noteName}`);
}
export async function endTask(chatId: string): Promise<string | null> {
const task = activeTasks[chatId];
if (!task) return null;
log.info("Ending note", task.noteName);
let finalNote = `# ${task.noteName}\n\n`;
const photoTasks: { content: ArrayBuffer; path: string }[] = [];
let photoIndex = 0;
for (const entry of task.entries) {
if (entry.type === "text") {
finalNote += entry.content + "\n\n";
} else if (entry.type === "voice") {
try {
log.info("Converting OGG to MP3");
const mp3Data = await convertOggToMp3(entry.content as ArrayBuffer);
log.info("Finished converting OGG to MP3, transcribing...");
const transcript = await transcribe(mp3Data);
finalNote += `**Voice Transcript:**\n${transcript}\n\n`;
log.info("Finished transcribing");
} catch (error) {
log.error(error);
finalNote += "**[Voice message could not be transcribed]**\n\n";
}
} else if (entry.type === "photo") {
const photoUrl = `${
task.noteName.replace(/\.md$/, "")
}/photo-${photoIndex++}.jpg`;
finalNote += `**Photo**:\n ${photoUrl}\n\n`;
photoTasks.push({
content: entry.content as ArrayBuffer,
path: photoUrl,
});
}
}
try {
for (const entry of photoTasks) {
await createResource(entry.path, entry.content);
}
} catch (err) {
log.error("Error creating photo document:", err);
}
try {
await createResource(task.noteName, finalNote);
} catch (error) {
log.error("Error creating document:", error);
return error instanceof Error
? error.toString()
: "Error creating document";
}
delete activeTasks[chatId];
log.debug({ finalNote });
return finalNote;
}
export function addTextEntry(chatId: string, text: string) {
const task = activeTasks[chatId];
if (!task) return;
const entry = { type: "text", content: text };
log.debug("New Entry", entry);
task.entries.push(entry);
}
export function addVoiceEntry(chatId: string, buffer: ArrayBufferLike) {
const task = activeTasks[chatId];
if (!task) return;
const entry = { type: "voice", content: buffer };
log.debug("New Entry", entry);
task.entries.push(entry);
}
export function addPhotoEntry(
chatId: string,
buffer: ArrayBufferLike,
fileName: string,
) {
const task = activeTasks[chatId];
if (!task) return;
const entry = { type: "photo", content: buffer, fileName };
log.debug("New Entry", entry);
task.entries.push(entry);
}

65
lib/telegram.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Bot } from "https://deno.land/x/grammy@v1.36.1/mod.ts";
import { TELEGRAM_API_KEY } from "@lib/env.ts";
import { createLogger } from "./log/index.ts";
const bot = new Bot(TELEGRAM_API_KEY);
const log = createLogger("telegram");
import * as manager from "./taskManager.ts";
async function downloadFile(filePath: string): Promise<Uint8Array> {
log.info(`Downloading file from path: ${filePath}`);
const url = `https://api.telegram.org/file/bot${bot.token}/${filePath}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to download file: " + response.statusText);
}
log.info("File downloaded successfully");
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
bot.command("start", async (ctx) => {
log.info("Received /start command");
const [_, noteName] = ctx.message?.text?.split(" ") || [];
if (!noteName) {
return ctx.reply("Please provide a note name. Usage: /start NoteName");
}
manager.startTask(ctx.chat.id.toString(), noteName);
await ctx.reply(`Started note: ${noteName}`);
});
bot.command("end", async (ctx) => {
log.info("Received /end command");
const finalNote = await manager.endTask(ctx.chat.id.toString());
if (!finalNote) return ctx.reply("No active note found.");
try {
await ctx.reply("Note complete. Here is your markdown:");
await ctx.reply(finalNote, { parse_mode: "MarkdownV2" });
} catch (err) {
console.error("Error sending final note:", err);
}
});
bot.on("message:text", (ctx) => {
log.info("Received text message");
manager.addTextEntry(ctx.chat.id.toString(), ctx.message.text);
});
bot.on("message:voice", async (ctx) => {
log.info("Received photo message");
log.info("Received voice message");
const file = await ctx.getFile();
const buffer = await downloadFile(file.file_path!);
manager.addVoiceEntry(ctx.chat.id.toString(), buffer.buffer);
});
bot.on("message:photo", async (ctx) => {
const file = await ctx.getFile();
const buffer = await downloadFile(file.file_path!);
const fileName = file.file_path!.split("/").pop()!;
manager.addPhotoEntry(ctx.chat.id.toString(), buffer.buffer, fileName);
});
bot.start();

View File

@@ -1,10 +1,6 @@
import * as thumbhash from "thumbhash";
export function generateThumbhash(
buffer: ArrayLike<number>,
w: number,
h: number,
) {
export function generateThumbhash(buffer: Uint8Array, w: number, h: number) {
const hash = thumbhash.rgbaToThumbHash(w, h, buffer);
return [hash, thumbhash.thumbHashToAverageRGBA(hash)] as const;
}

View File

@@ -47,7 +47,7 @@ export function absolutizeDomUrls(dom: JSDOM, domain: string): void {
document
.querySelectorAll("img[srcset], source[srcset]")
.forEach((el) => {
.forEach((el: HTMLImageElement) => {
const v = el.getAttribute("srcset");
if (!v) return;
const abs = absolutizeSrcset(v, base);
@@ -55,7 +55,7 @@ export function absolutizeDomUrls(dom: JSDOM, domain: string): void {
});
document.querySelectorAll("[style]").forEach(
(el) => {
(el: HTMLElement) => {
const v = el.getAttribute("style");
if (!v) return;
const abs = absolutizeCssUrls(v, base);
@@ -73,7 +73,7 @@ export function absolutizeDomUrls(dom: JSDOM, domain: string): void {
document
.querySelectorAll('meta[http-equiv="refresh" i][content]')
.forEach((meta) => {
.forEach((meta: HTMLMetaElement) => {
const content = meta.getAttribute("content") || "";
const abs = absolutizeMetaRefresh(content, base);
if (abs !== content) meta.setAttribute("content", abs);
@@ -168,20 +168,10 @@ function absolutizeMetaRefresh(content: string, base: string): string {
const turndownService = new TurndownService();
export interface WebScrapeResult {
title?: string;
image?: string;
published?: string;
content: string;
schemaOrgData?: { author?: { name?: string } };
markdown: string;
dom: JSDOM["window"]["document"];
}
export async function webScrape(
url: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
): JSDOM {
const u = new URL(url);
const html = await fetchHtmlWithPlaywright(url, streamResponse);
const dom = new JSDOM(html);

View File

@@ -56,10 +56,12 @@ export interface ContentDetails {
definition: string;
caption: string;
licensedContent: boolean;
contentRating: Record<string, unknown>;
contentRating: ContentRating;
projection: string;
}
export interface ContentRating {}
export interface Statistics {
viewCount: string;
likeCount: string;

16
main.ts
View File

@@ -1,8 +1,12 @@
import { App, staticFiles } from "fresh";
import { type State } from "./utils.ts";
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
export const app = new App<State>();
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
// import "@lib/telegram.ts";
app.use(staticFiles());
app.fsRoutes();
await start(manifest, config);

View File

@@ -1,18 +1,7 @@
import { Head } from "fresh/runtime";
import { Head } from "$fresh/runtime.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { HttpError, PageProps } from "fresh";
export default function ErrorPage(props: PageProps) {
const error = props.error; // Contains the thrown Error or HTTPError
if (error instanceof HttpError) {
const status = error.status; // HTTP status code
// Render a 404 not found page
if (status === 404) {
return <h1>404 - Page not found</h1>;
}
}
export default function Error404() {
return (
<>
<Head>
@@ -20,7 +9,7 @@ export default function ErrorPage(props: PageProps) {
</Head>
<MainLayout url="">
<div class="px-8 text-white mt-10">
<div class="max-w-3xl mx-auto flex flex-col items-center justify-center">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<h1 class="text-4xl font-bold">404 - Page not found</h1>
<p class="my-4">
The page you were looking for doesn't exist.

View File

@@ -1,16 +1,24 @@
import { define } from "../utils.ts";
// deno-lint-ignore-file react-no-danger
import { PageProps } from "$fresh/server.ts";
import { Partial } from "$fresh/runtime.ts";
export default function App({ Component }: PageProps) {
const globalCss = Deno.readTextFileSync("./static/global.css");
export default define.page(function ({ Component }) {
return (
<html>
<head>
<link rel="stylesheet" href="/prism-material-dark.css" />
<link rel="stylesheet" href="/styles.css" />
<link
rel="icon"
type="image/png"
href="/favicon.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141218" />
<link
rel="preload"
href="/fonts/work-sans-v18-latin-regular.woff2"
@@ -23,25 +31,15 @@ export default define.page(function ({ Component }) {
as="font"
type="font/woff2"
/>
<link rel="stylesheet" href="/global.css" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141218" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style
dangerouslySetInnerHTML={{
__html: Deno.readTextFileSync("./static/global.css"),
}}
>
</style>
<style dangerouslySetInnerHTML={{ __html: globalCss }} />
<title>Memorium</title>
</head>
<body>
<body f-client-nav>
<Partial name="body">
<Component />
</Partial>
</body>
<script src="/thumbhash.js" type="module" async defer />
</html>
);
});
}

View File

@@ -1,10 +1,10 @@
import { PageProps } from "$fresh/server.ts";
import { resources } from "@lib/resources.ts";
import { Link } from "@islands/Link.tsx";
import { Emoji } from "@components/Emoji.tsx";
import KMenuButton from "@islands/KMenuButton.tsx";
import { PageProps } from "fresh";
import { define } from "../utils.ts";
export default define.layout(function ({ Component }: PageProps) {
export default function MyLayout({ Component }: PageProps) {
return (
<div
class="md:grid mx-auto"
@@ -14,12 +14,12 @@ export default define.layout(function ({ Component }: PageProps) {
<nav class="min-h-fit rounded-3xl p-3 grid gap-3 fixed t-0">
{Object.values(resources).map((m) => {
return (
<a
<Link
href={m.link}
class="flex items-center gap-2 text-white data-current:bg-white data-current:text-black p-3 text-xl w-full rounded-2xl"
class="flex items-center gap-2 text-white data-[current]:bg-white data-[current]:text-black p-3 text-xl w-full rounded-2xl"
>
<Emoji class="w-6 h-6" name={m.emoji} /> {m.name}
</a>
</Link>
);
})}
</nav>
@@ -30,4 +30,4 @@ export default define.layout(function ({ Component }: PageProps) {
<KMenuButton />
</div>
);
});
}

View File

@@ -1,39 +1,27 @@
//routes/middleware-error-handler/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
import { DomainError } from "@lib/errors.ts";
import { getCookies } from "@std/http/cookie";
import { verify } from "@zaubrik/djwt";
import { verify } from "https://deno.land/x/djwt@v2.2/mod.ts";
import * as perf from "@lib/performance.ts";
import { JWT_SECRET } from "@lib/env.ts";
import { define } from "../utils.ts";
function importKey(secret: string) {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign", "verify"],
);
}
const authMiddleware = define.middleware(async function (
ctx,
export async function handler(
req: Request,
ctx: FreshContext,
) {
const req = ctx.req;
try {
performance.mark("a");
const allCookies = getCookies(req.headers);
const sessionCookie = allCookies["session_cookie"];
if (!ctx.state.session && sessionCookie && JWT_SECRET) {
try {
const payload = await verify<typeof ctx.state.session>(
sessionCookie,
await importKey(JWT_SECRET),
);
const payload = await verify(sessionCookie, JWT_SECRET, "HS512");
if (payload) {
ctx.state.session = payload;
}
} catch (_err) {
//
console.log({ _err });
}
}
@@ -56,6 +44,4 @@ const authMiddleware = define.middleware(async function (
status: 500,
});
}
});
export default [authMiddleware];
}

32
routes/admin/cache/index.tsx vendored Normal file
View File

@@ -0,0 +1,32 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getCacheInfo } from "@lib/cache.ts";
export const handler: Handlers<
{ cacheInfo: ReturnType<typeof getCacheInfo> }
> = {
GET(_, ctx) {
return ctx.render({ cacheInfo: getCacheInfo() });
},
};
export default function Greet(
props: PageProps<
{ cacheInfo: ReturnType<typeof getCacheInfo> }
>,
) {
const { cacheInfo } = props.data;
return (
<MainLayout
url={props.url}
title="Recipes"
context={{ type: "recipes" }}
>
<code>
<pre class="text-white">
{JSON.stringify(cacheInfo, null, 2)}
</pre>
</code>
</MainLayout>
);
}

View File

@@ -0,0 +1,78 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { getLogs, Log } from "@lib/log/index.ts";
import { formatDate } from "@lib/string.ts";
import { renderMarkdown } from "@lib/markdown.ts";
const renderLog = (t: unknown) =>
renderMarkdown(`\`\`\`js
${typeof t === "string" ? t : JSON.stringify(t, null, 2).trim()}
\`\`\``);
export const handler: Handlers = {
async GET(_, ctx) {
const logs = await getLogs();
if (!("session" in ctx.state)) {
throw new AccessDeniedError();
}
return ctx.render({
logs: logs.map((l) => {
return {
...l,
html: l.args.map(renderLog).join("<br/>"),
};
}),
});
},
};
function LogLine(
{ log }: {
log: Log & { html?: string };
},
) {
return (
<div
class="mt-4 flex flex-col gap-2 bg-gray-900 px-4 py-2 rounded-2xl max-w-3xl"
style={{ background: "var(--light)" }}
>
<div class="flex gap-2">
<span class="bg-gray-600 py-1 px-2 text-xs rounded-xl text-white">
{log.date.getHours().toString().padStart(2, "0")}:{log.date
.getMinutes().toString().padStart(2, "0")}:{log.date.getSeconds()
.toString().padStart(2, "0")} {formatDate(log.date)}
</span>
<span class="bg-gray-600 py-1 px-2 text-xs rounded-xl text-white">
{log.scope}
</span>
<span class="bg-gray-600 py-1 px-2 text-xs rounded-xl text-white">
{log.level}
</span>
</div>
<div
class="text-white"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: log.html ?? "" }}
/>
</div>
);
}
export default function Greet(
{ data: { logs }, url }: PageProps<{ logs: Log[] }>,
) {
return (
<MainLayout url={url}>
<h1 class="text-white text-4xl">Logs</h1>
{logs.map((r) => {
return (
<LogLine
log={r}
/>
);
})}
</MainLayout>
);
}

View File

@@ -0,0 +1,89 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPerformances, PerformanceRes } from "@lib/performance.ts";
import { AccessDeniedError } from "@lib/errors.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const performances = await getPerformances();
if (!("session" in ctx.state)) {
throw new AccessDeniedError();
}
return ctx.render({ performances });
},
};
function PerformanceLine(
{ maximum, data: [amount, min, average, max], url }: {
maximum: number;
url: string;
data: readonly [number, number, number, number];
},
) {
return (
<div
class="mt-10 flex flex-col gap-2 bg-gray-900 px-4 py-2 rounded-2xl"
style={{ background: "var(--light)" }}
>
<div style={{ color: "var(--foreground)" }}>
{url}
</div>
<div class="flex gap-2">
<span class="bg-gray-600 p-1 text-xs rounded-xl text-white">
{Math.floor(average / 1000)}ms
</span>
<span class="bg-gray-600 p-1 text-xs rounded-xl text-white">
{amount}x
</span>
</div>
<div class="" style={{ maxWidth: "75vw" }}>
<div
class="h-2 bg-red-200 rounded"
style={{
width: `${Math.floor((max / maximum) * 100)}%`,
borderRadius: "1rem 1rem 1rem 0px",
minWidth: "5px",
}}
/>
<div
class="h-2 bg-green-200 rounded"
style={{
width: `${Math.floor((average / maximum) * 100)}%`,
borderStartEndRadius: "0px",
borderRadius: "0px 0px 1rem 0px",
minWidth: "5px",
}}
/>
<div
class="h-2 bg-yellow-200 rounded"
style={{
width: `${Math.floor((min / maximum) * 100)}%`,
borderRadius: "0px 0px 1rem 1rem",
minWidth: "5px",
}}
/>
</div>
</div>
);
}
export default function Greet(
{ data: { performances }, url }: PageProps<{ performances: PerformanceRes }>,
) {
return (
<MainLayout url={url}>
<h1 class="text-white text-4xl ">Performance</h1>
{performances.res.map((r) => {
return (
<PerformanceLine
maximum={performances.max}
url={r.url}
data={r.data}
/>
);
})}
</MainLayout>
);
}

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
export const handler: Handlers = {
async GET(_, ctx) {
const article = await fetchResource(`articles/${ctx.params.name}`);
return json(article);
},
});
};

View File

@@ -1,3 +1,5 @@
import { Handlers } from "$fresh/server.ts";
import { Defuddle } from "defuddle/node";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
@@ -5,7 +7,6 @@ import * as unsplash from "@lib/unsplash.ts";
import { getYoutubeVideoDetails } from "@lib/youtube.ts";
import {
extractYoutubeId,
fileExtension,
formatDate,
isYoutubeLink,
safeFileName,
@@ -15,7 +16,7 @@ import { createLogger } from "@lib/log/index.ts";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { ArticleResource } from "@lib/marka/schema.ts";
import { define } from "../../../../utils.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
const log = createLogger("api/article");
@@ -84,31 +85,29 @@ async function processCreateArticle(
streamResponse.info("downloading article");
const scrapeResult = await webScrape(fetchUrl, streamResponse);
const result = await webScrape(fetchUrl, streamResponse);
log.debug("downloaded and parse parsed", scrapeResult);
log.debug("downloaded and parse parsed", result);
streamResponse.info("parsed article, creating tags with openai");
const aiMeta = await openai.extractArticleMetadata(scrapeResult.markdown);
const aiMeta = await openai.extractArticleMetadata(result.markdown);
streamResponse.info("postprocessing article");
const title = scrapeResult?.title || aiMeta?.headline || "";
const title = result?.title || aiMeta?.headline || "";
let coverImagePath: string | undefined = undefined;
if (scrapeResult?.image?.length) {
log.debug("using local image for cover image", {
image: scrapeResult.image,
});
if (result?.image?.length) {
log.debug("using local image for cover image", { image: result.image });
coverImagePath = await fetchAndStoreCover(
scrapeResult.image,
result.image,
title,
streamResponse,
);
} else {
const urlPath = await getUnsplashCoverImage(
scrapeResult.markdown,
result.markdown,
streamResponse,
);
coverImagePath = await fetchAndStoreCover(urlPath, title, streamResponse);
@@ -120,15 +119,15 @@ async function processCreateArticle(
const newArticle: ArticleResource["content"] = {
_type: "Article",
headline: title,
articleBody: scrapeResult.markdown,
articleBody: result.markdown,
url: fetchUrl,
datePublished: formatDate(
scrapeResult?.published || aiMeta?.datePublished || undefined,
result?.published || aiMeta?.datePublished || undefined,
),
image: coverImagePath,
author: {
_type: "Person",
name: (scrapeResult.schemaOrgData?.author?.name || aiMeta?.author || "")
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace(
"@",
"twitter:",
@@ -202,9 +201,8 @@ async function processCreateYoutubeVideo(
streamResponse.send({ type: "finished", url: filename });
}
export const handler = define.handlers({
GET(ctx) {
const req = ctx.req;
export const handler: Handlers = {
GET(req, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -241,4 +239,4 @@ export const handler = define.handlers({
return streamResponse.response;
},
});
};

View File

@@ -1,4 +1,6 @@
import { fileExtension, formatDate, safeFileName } from "@lib/string.ts";
import { FreshContext, Handlers } from "$fresh/server.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName } from "@lib/string.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import {
AccessDeniedError,
@@ -11,7 +13,6 @@ import { webScrape } from "@lib/webScraper.ts";
import * as openai from "@lib/openai.ts";
import * as unsplash from "@lib/unsplash.ts";
import { createLogger } from "@lib/log/index.ts";
import { define } from "../../../../utils.ts";
function ext(str: string) {
try {
@@ -162,8 +163,10 @@ async function processEnhanceArticle(
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
export const handler = define.handlers({
POST: (ctx) => {
const POST = (
_req: Request,
ctx: FreshContext,
): Response => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -181,5 +184,8 @@ export const handler = define.handlers({
});
return streamResponse.response;
},
});
};
export const handler: Handlers = {
POST,
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
export const handler: Handlers = {
async GET() {
const articles = await fetchResource("articles");
return json(articles?.content);
},
});
};

View File

@@ -1,4 +1,5 @@
import { create, getNumericDate } from "@zaubrik/djwt";
import { Handlers } from "$fresh/server.ts";
import { create, getNumericDate } from "https://deno.land/x/djwt@v2.2/mod.ts";
import { oauth2Client } from "@lib/auth.ts";
import { getCookies, setCookie } from "@std/http/cookie";
import { codeChallengeMap } from "./login.ts";
@@ -8,16 +9,15 @@ import { BadRequestError } from "@lib/errors.ts";
import { db } from "@lib/db/sqlite.ts";
import { userTable } from "@lib/db/schema.ts";
import { eq } from "drizzle-orm";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
export const handler: Handlers = {
async GET(request) {
if (!JWT_SECRET) {
throw new BadRequestError();
}
// Exchange the authorization code for an access token
const cookies = getCookies(ctx.req.headers);
const cookies = getCookies(request.headers);
const stored = codeChallengeMap.get(cookies["code_challenge"]);
if (!stored) {
@@ -26,7 +26,7 @@ export const handler = define.handlers({
const { codeVerifier, redirect } = stored;
const tokens = await oauth2Client.code.getToken(ctx.req.url, {
const tokens = await oauth2Client.code.getToken(request.url, {
codeVerifier,
});
@@ -53,23 +53,11 @@ export const handler = define.handlers({
user = res[0];
}
if (!JWT_SECRET) {
throw new BadRequestError();
}
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(JWT_SECRET),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign", "verify"],
);
const jwt = await create({ alg: "HS512", type: "JWT" }, {
id: user.id,
name: user.name,
exp: getNumericDate(SESSION_DURATION),
}, key);
}, JWT_SECRET);
const headers = new Headers({
location: redirect || "/",
@@ -90,4 +78,4 @@ export const handler = define.handlers({
status: 302,
});
},
});
};

View File

@@ -1,15 +1,14 @@
import { Handlers } from "$fresh/server.ts";
import { oauth2Client } from "@lib/auth.ts";
import { setCookie } from "@std/http/cookie";
import { define } from "../../../utils.ts";
export const codeChallengeMap = new Map<
string,
{ codeVerifier: string; redirect?: string }
>();
export const handler = define.handlers({
async GET(ctx) {
const req = ctx.req;
export const handler: Handlers = {
async GET(req) {
const url = new URL(req.url);
const { codeVerifier, uri } = await oauth2Client.code.getAuthorizationUri();
@@ -34,4 +33,4 @@ export const handler = define.handlers({
status: 302,
});
},
});
};

View File

@@ -1,9 +1,8 @@
import { deleteCookie } from "@std/http/cookie";
import { define } from "../../../utils.ts";
import { Handlers } from "$fresh/server.ts";
export const handler = define.handlers({
GET(ctx) {
const req = ctx.req;
export const handler: Handlers = {
GET(req) {
const url = new URL(req.url);
const redirect = decodeURIComponent(url.searchParams.get("redirect") || "");
@@ -20,4 +19,4 @@ export const handler = define.handlers({
status: 302,
});
},
});
};

View File

@@ -0,0 +1,75 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { getBookDetails } from "@lib/hardcover.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const book = await fetchResource(`books/${ctx.params.name}`);
return json(book?.content);
},
async POST(_, ctx) {
const session = ctx.state.session;
if (!session) throw new AccessDeniedError();
const hardcoverId = ctx.params.name;
if (!hardcoverId) throw new AccessDeniedError();
const bookDetails = await getBookDetails(hardcoverId);
if (!bookDetails) {
throw new Error("Book not found on Hardcover");
}
const title = bookDetails.title || hardcoverId;
const authorName = bookDetails.author_names?.[0] || "";
const isbn = bookDetails.isbn13 || bookDetails.isbn || "";
const releaseDate = bookDetails.release_year
? `${bookDetails.release_year}-01-01`
: undefined;
let finalPath = "";
if (bookDetails.image) {
try {
const response = await fetch(bookDetails.image);
if (response.ok) {
const buffer = await response.arrayBuffer();
const extension = fileExtension(bookDetails.image);
finalPath = `books/images/${safeFileName(title)}_cover.${extension}`;
await createResource(finalPath, buffer);
}
} catch {
console.log("Failed to download book cover");
}
}
const book: ReviewResource["content"] = {
_type: "Review",
headline: title,
subtitle: bookDetails.subtitle,
bookBody: bookDetails.description || "",
link: `https://hardcover.app/books/${bookDetails.slug}`,
image: finalPath ? `resources/${finalPath}` : undefined,
datePublished: formatDate(releaseDate),
author: authorName ? {
_type: "Person",
name: authorName,
} : undefined,
itemReviewed: {
name: title,
},
};
console.log("Creating book resource:", JSON.stringify(book, null, 2));
const fileName = toUrlSafeString(title);
await createResource(`books/${fileName}.md`, book);
return json({ name: fileName });
},
};

View File

@@ -0,0 +1,242 @@
import { Handlers } from "$fresh/server.ts";
import { Defuddle } from "defuddle/node";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import * as unsplash from "@lib/unsplash.ts";
import { getYoutubeVideoDetails } from "@lib/youtube.ts";
import {
extractYoutubeId,
formatDate,
isYoutubeLink,
safeFileName,
toUrlSafeString,
} from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
const log = createLogger("api/book");
async function getUnsplashCoverImage(
content: string,
streamResponse: ReturnType<typeof createStreamResponse>,
): Promise<string | undefined> {
try {
streamResponse.info("creating unsplash search term");
const searchTerm = await openai.createUnsplashSearchTerm(content);
if (!searchTerm) return;
streamResponse.info(`searching for ${searchTerm}`);
const unsplashUrl = await unsplash.getImageBySearchTerm(searchTerm);
return unsplashUrl;
} catch (e) {
log.error("Failed to get unsplash cover image", e);
return undefined;
}
}
function ext(str: string) {
try {
const u = new URL(str);
if (u.searchParams.has("fm")) {
return u.searchParams.get("fm")!;
}
return fileExtension(u.pathname);
} catch (_e) {
return fileExtension(str);
}
}
async function fetchAndStoreCover(
imageUrl: string | undefined,
title: string,
streamResponse?: ReturnType<typeof createStreamResponse>,
): Promise<string | undefined> {
if (!imageUrl) return;
const imagePath = `books/images/${safeFileName(title)}_cover.${
ext(imageUrl)
}`;
try {
streamResponse?.info("downloading image");
const res = await fetch(imageUrl);
streamResponse?.info("saving image");
if (!res.ok) {
console.log(`Failed to download remote image: ${imageUrl}`, res.status);
return;
}
const buffer = await res.arrayBuffer();
await createResource(imagePath, buffer);
return `resources/${imagePath}`;
} catch (err) {
console.log(`Failed to save image: ${imageUrl}`, err);
return undefined;
}
}
async function processCreateBook(
{ fetchUrl, streamResponse }: {
fetchUrl: string;
streamResponse: ReturnType<typeof createStreamResponse>;
},
) {
log.info("create book from url", { url: fetchUrl });
streamResponse.info("downloading book");
const result = await webScrape(fetchUrl, streamResponse);
log.debug("downloaded and parse parsed", result);
streamResponse.info("parsed book, creating tags with openai");
const aiMeta = await openai.extractArticleMetadata(result.markdown);
streamResponse.info("postprocessing book");
const title = result?.title || aiMeta?.headline || "";
let coverImagePath: string | undefined = undefined;
if (result?.image?.length) {
log.debug("using local image for cover image", { image: result.image });
coverImagePath = await fetchAndStoreCover(
result.image,
title,
streamResponse,
);
} else {
const urlPath = await getUnsplashCoverImage(
result.markdown,
streamResponse,
);
coverImagePath = await fetchAndStoreCover(urlPath, title, streamResponse);
log.debug("using unsplash for cover image", { image: coverImagePath });
}
const url = toUrlSafeString(title);
const newBook: BookResource["content"] = {
_type: "Book",
headline: title,
bookBody: result.markdown,
url: fetchUrl,
datePublished: formatDate(
result?.published || aiMeta?.datePublished || undefined,
),
image: coverImagePath,
author: {
_type: "Person",
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace(
"@",
"twitter:",
),
},
} as const;
streamResponse.info("writing to disk");
log.debug("writing to disk", {
...newBook,
bookBody: newBook.bookBody?.slice(0, 200),
});
await createResource(`books/${url}.md`, newBook);
streamResponse.send({ type: "finished", url });
}
async function processCreateYoutubeVideo(
{ fetchUrl, streamResponse }: {
fetchUrl: string;
streamResponse: ReturnType<typeof createStreamResponse>;
},
) {
log.info("create youtube book from url", {
url: fetchUrl,
});
streamResponse.info("getting video infos from youtube api");
const youtubeId = extractYoutubeId(fetchUrl);
const video = await getYoutubeVideoDetails(youtubeId);
streamResponse.info("shortening title with openai");
const videoTitle = await openai.shortenTitle(video.snippet.title) ||
video.snippet.title;
const thumbnail = video?.snippet?.thumbnails?.maxres;
const coverImagePath = await fetchAndStoreCover(
thumbnail.url,
videoTitle || video.snippet.title,
streamResponse,
);
const newBook: BookResource["content"] = {
_type: "Book",
headline: video.snippet.title,
bookBody: video.snippet.description,
image: coverImagePath,
url: fetchUrl,
datePublished: formatDate(video.snippet.publishedAt),
author: {
_type: "Person",
name: video.snippet.channelTitle,
},
};
streamResponse.info("creating book");
const filename = toUrlSafeString(videoTitle);
await createResource(
`books/${filename}.md`,
newBook,
);
streamResponse.info("finished");
streamResponse.send({ type: "finished", url: filename });
}
export const handler: Handlers = {
GET(req, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const url = new URL(req.url);
const fetchUrl = url.searchParams.get("url");
if (!fetchUrl || !isValidUrl(fetchUrl)) {
throw new BadRequestError();
}
const streamResponse = createStreamResponse();
if (isYoutubeLink(fetchUrl)) {
processCreateYoutubeVideo({ fetchUrl, streamResponse }).then(
(book) => {
log.debug("created book from youtube", { book });
},
).catch((err) => {
log.error(err);
}).finally(() => {
streamResponse.cancel();
});
} else {
processCreateBook({ fetchUrl, streamResponse }).then((book) => {
log.debug("created book from link", { book });
}).catch((err) => {
log.error(err);
}).finally(() => {
streamResponse.cancel();
});
}
return streamResponse.response;
},
};

View File

@@ -0,0 +1,244 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import {
AccessDeniedError,
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { webScrape } from "@lib/webScraper.ts";
import * as openai from "@lib/openai.ts";
import { getBookDetails } from "@lib/hardcover.ts";
import { createLogger } from "@lib/log/index.ts";
function ext(str: string) {
try {
const u = new URL(str);
if (u.searchParams.has("fm")) {
return u.searchParams.get("fm")!;
}
return fileExtension(u.pathname);
} catch (_e) {
return fileExtension(str);
}
}
const log = createLogger("api/book/enhance");
async function fetchAndStoreCover(
imageUrl: string | undefined,
title: string,
): Promise<string | undefined> {
if (!imageUrl) return;
const imagePath = `books/images/${safeFileName(title)}_cover.${
ext(imageUrl)
}`;
try {
const res = await fetch(imageUrl);
if (!res.ok) {
log.error(`Failed to download remote image: ${imageUrl}`, {
status: res.status,
});
return;
}
const buffer = await res.arrayBuffer();
await createResource(imagePath, buffer);
return `resources/${imagePath}`;
} catch (err) {
log.error(`Failed to save image: ${imageUrl}`, err);
return undefined;
}
}
async function processEnhanceFromHardcover(
name: string,
hardcoverSlug: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
streamResponse.info("fetching from Hardcover");
const bookDetails = await getBookDetails(hardcoverSlug);
if (!bookDetails) {
throw new NotFoundError("Book not found on Hardcover");
}
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const title = bookDetails.title || book.content?.headline || "";
const authorName = bookDetails.author_names?.[0] || "";
streamResponse.info("updating cover image");
if (bookDetails.image && !book.content?.image) {
const coverPath = await fetchAndStoreCover(bookDetails.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
if (!book.content?.headline || book.content.headline !== bookDetails.title) {
book.content.headline = bookDetails.title;
}
if (!book.content?.subtitle && bookDetails.subtitle) {
book.content.subtitle = bookDetails.subtitle;
}
if (!book.content?.isbn && (bookDetails.isbn13 || bookDetails.isbn)) {
book.content.isbn = bookDetails.isbn13 || bookDetails.isbn;
}
if (bookDetails.rating && !book.content?.reviewRating) {
book.content.reviewRating = {
ratingValue: bookDetails.rating,
bestRating: 5,
worstRating: 1,
};
}
if (!book.content?.datePublished && bookDetails.release_year) {
book.content.datePublished = formatDate(`${bookDetails.release_year}-01-01`);
}
const newKeywords = [
...(bookDetails.genres?.map((g: { name: string }) => g.name) || []),
...(bookDetails.tags?.map((t: { name: string }) => t.name) || []),
].filter(Boolean);
if (newKeywords.length > 0) {
book.content.keywords = [
...(book.content.keywords || []),
...newKeywords,
];
}
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceFromUrl(
name: string,
fetchUrl: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
log.info("enhancing book from url", { url: fetchUrl });
streamResponse.info("scraping url");
const result = await webScrape(fetchUrl, streamResponse);
streamResponse.info("parsing content");
log.debug("downloaded and parsed", result);
streamResponse.info("extracting metadata with openai");
const aiMeta = await openai.extractArticleMetadata(result.markdown);
const title = result?.title || aiMeta?.headline ||
name;
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
book.content ??= {
_type: "Book",
headline: title,
url: fetchUrl,
};
if (!book.content.bookBody && result.markdown) {
book.content.bookBody = result.markdown;
}
book.content.datePublished ??= formatDate(
result?.published || aiMeta?.datePublished || undefined,
);
if (!book.content.author?.name || book.content.author.name === "") {
book.content.author = {
_type: "Person",
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace("@", "twitter:"),
};
}
if (!book.content.image && result?.image?.length) {
const coverPath = await fetchAndStoreCover(result.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
log.debug("writing to disk", {
name: name,
book: {
...book,
content: {
...book.content,
bookBody: book.content.bookBody?.slice(0, 200),
},
},
});
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceBook(
name: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const hardcoverUrl = book.content?.url;
if (hardcoverUrl?.includes("hardcover.app")) {
const match = hardcoverUrl.match(/books\/(.+)/);
if (match && match[1]) {
return processEnhanceFromHardcover(name, match[1], streamResponse);
}
}
if (hardcoverUrl) {
return processEnhanceFromUrl(name, hardcoverUrl, streamResponse);
}
throw new BadRequestError("Book has no URL to enhance from.");
}
const POST = (
_req: Request,
ctx: FreshContext,
): Response => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const streamResponse = createStreamResponse();
processEnhanceBook(ctx.params.name, streamResponse)
.catch((err) => {
log.error(err);
streamResponse.error(err.message);
})
.finally(() => {
streamResponse.cancel();
});
return streamResponse.response;
};
export const handler: Handlers = {
POST,
};

10
routes/api/books/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {
const books = await fetchResource("books");
return json(books?.content);
},
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { documentTable } from "@lib/db/schema.ts";
import { db } from "@lib/db/sqlite.ts";
import { json } from "@lib/helpers.ts";
import { caches } from "@lib/cache.ts";
import { define } from "../../utils.ts";
export const handler = define.handlers({
export const handler: Handlers = {
async DELETE() {
for (const cache of caches.values()) {
cache.clear();
@@ -12,4 +12,4 @@ export const handler = define.handlers({
await db.delete(documentTable).run();
return json({ status: "ok" });
},
});
};

View File

@@ -0,0 +1,28 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { searchBook } from "@lib/hardcover.ts";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
const GET = async (
req: Request,
ctx: FreshContext,
) => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const u = new URL(req.url);
const query = u.searchParams.get("q");
if (!query) {
throw new BadRequestError();
}
const books = await searchBook(query);
console.log("Hardcover search results:", JSON.stringify(books).slice(0, 500));
return new Response(JSON.stringify(books));
};
export const handler: Handlers = {
GET,
};

View File

@@ -1,6 +1,6 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { getImageContent } from "@lib/image.ts";
import { createLogger } from "@lib/log/index.ts";
import { Context } from "fresh";
const log = createLogger("api/image");
@@ -69,9 +69,9 @@ async function generateETag(content: Uint8Array<ArrayBuffer>): Promise<string> {
}"`;
}
async function GET(ctx: Context<unknown>): Promise<Response> {
async function GET(req: Request, _ctx: FreshContext): Promise<Response> {
try {
const url = new URL(ctx.req.url);
const url = new URL(req.url);
const params = parseParams(url);
if (typeof params === "string") {
@@ -106,6 +106,6 @@ async function GET(ctx: Context<unknown>): Promise<Response> {
}
}
export const handler = {
export const handler: Handlers = {
GET,
};

View File

@@ -1,8 +1,8 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { define } from "../../utils.ts";
export const handler = define.handlers({
export const handler: Handlers = {
GET() {
return json([]);
},
});
};

View File

@@ -1,9 +1,9 @@
import { Handlers } from "$fresh/server.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import { define } from "../../utils.ts";
const activeResponses: ReturnType<typeof createStreamResponse>[] = [];
export const handler = define.handlers({
export const handler: Handlers = {
GET() {
const r = createStreamResponse();
@@ -11,4 +11,4 @@ export const handler = define.handlers({
return r.response;
},
});
};

View File

@@ -1,7 +1,8 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import * as tmdb from "@lib/tmdb.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import {
fileExtension,
formatDate,
isString,
safeFileName,
@@ -10,14 +11,13 @@ import {
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
export const handler: Handlers = {
async GET(_, ctx) {
const movie = await fetchResource(`movies/${ctx.params.name}`);
return json(movie?.content);
},
async POST(ctx) {
async POST(_, ctx) {
const session = ctx.state.session;
if (!session) throw new AccessDeniedError();
@@ -70,4 +70,4 @@ export const handler = define.handlers({
return json({ name: fileName });
},
});
};

View File

@@ -1,6 +1,7 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import * as tmdb from "@lib/tmdb.ts";
import {
fileExtension,
formatDate,
isString,
safeFileName,
@@ -15,10 +16,11 @@ import {
import { createRecommendationResource } from "@lib/recommendation.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
import { define } from "../../../../utils.ts";
export const handler = define.handlers({
POST: async function (ctx) {
const POST = async (
req: Request,
ctx: FreshContext,
): Promise<Response> => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -31,7 +33,7 @@ export const handler = define.handlers({
throw new NotFoundError();
}
const body = await ctx.req.json();
const body = await req.json();
const name = ctx.params.name;
const { tmdbId } = body;
if (!name || !tmdbId) {
@@ -86,5 +88,8 @@ export const handler = define.handlers({
createRecommendationResource(movie, movieDetails.overview);
return json(movie);
},
});
};
export const handler: Handlers = {
POST,
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
export const handler: Handlers = {
async GET() {
const movies = await fetchResource("movies");
return json(movies);
},
});
};

View File

@@ -1,11 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
const req = ctx.req;
export const handler: Handlers = {
async GET(req, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -16,8 +15,9 @@ export const handler = define.handlers({
throw new BadRequestError();
}
console.log(s);
const resources = await searchResource(s);
return json(resources);
},
});
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
export const handler: Handlers = {
async GET(_, ctx) {
const recipe = await fetchResource(`recipes/${ctx.params.name}`);
return json(recipe);
},
});
};

View File

@@ -1,15 +1,16 @@
import { Handlers } from "$fresh/server.ts";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import { createLogger } from "@lib/log/index.ts";
import recipeSchema from "@lib/recipeSchema.ts";
import { fileExtension, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { safeFileName, toUrlSafeString } from "@lib/string.ts";
import { parseJsonLdToRecipeSchema } from "./parseJsonLd.ts";
import z from "zod";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { RecipeResource } from "@lib/marka/schema.ts";
import { define } from "../../../../utils.ts";
const log = createLogger("api/article");
@@ -92,9 +93,8 @@ async function processCreateRecipeFromUrl(
streamResponse.send({ type: "finished", url: id });
}
export const handler = define.handlers({
GET(ctx) {
const req = ctx.req;
export const handler: Handlers = {
GET(req, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -120,4 +120,4 @@ export const handler = define.handlers({
return streamResponse.response;
},
});
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
export const handler: Handlers = {
async GET() {
const recipes = await fetchResource("recipes");
return json(recipes);
},
});
};

View File

@@ -1,3 +1,4 @@
import { Handlers } from "$fresh/server.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import * as tmdb from "@lib/tmdb.ts";
import {
@@ -7,7 +8,6 @@ import {
import { AccessDeniedError } from "@lib/errors.ts";
import { listResources } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
import { define } from "../../../utils.ts";
async function processUpdateRecommendations(
streamResponse: ReturnType<typeof createStreamResponse>,
@@ -53,8 +53,8 @@ async function processUpdateRecommendations(
streamResponse.info("100% Finished");
}
export const handler = define.handlers({
GET(ctx) {
export const handler: Handlers = {
GET(_, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -65,4 +65,4 @@ export const handler = define.handlers({
return streamResponse.response;
},
});
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { getAllRecommendations } from "@lib/recommendation.ts";
import { json } from "@lib/helpers.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
async GET(ctx) {
export const handler: Handlers = {
async GET(_, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
@@ -34,4 +34,4 @@ export const handler = define.handlers({
keywords,
});
},
});
};

Some files were not shown because too many files have changed in this diff Show More