Compare commits
3 Commits
e65938ecc2
...
8ebfa9c5c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ebfa9c5c2
|
|||
|
e0bfbdd719
|
|||
|
c232794cc0
|
19
fresh.gen.ts
19
fresh.gen.ts
@@ -16,7 +16,12 @@ 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_callback from "./routes/api/auth/callback.ts";
|
||||||
import * as $api_auth_login from "./routes/api/auth/login.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_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_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_images_index from "./routes/api/images/index.ts";
|
||||||
import * as $api_index from "./routes/api/index.ts";
|
import * as $api_index from "./routes/api/index.ts";
|
||||||
import * as $api_logs from "./routes/api/logs.ts";
|
import * as $api_logs from "./routes/api/logs.ts";
|
||||||
@@ -40,6 +45,8 @@ 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 $api_tmdb_query from "./routes/api/tmdb/query.ts";
|
||||||
import * as $articles_name_ from "./routes/articles/[name].tsx";
|
import * as $articles_name_ from "./routes/articles/[name].tsx";
|
||||||
import * as $articles_index from "./routes/articles/index.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 $index from "./routes/index.tsx";
|
||||||
import * as $movies_name_ from "./routes/movies/[name].tsx";
|
import * as $movies_name_ from "./routes/movies/[name].tsx";
|
||||||
import * as $movies_index from "./routes/movies/index.tsx";
|
import * as $movies_index from "./routes/movies/index.tsx";
|
||||||
@@ -54,11 +61,13 @@ 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_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_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_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_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_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_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_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_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 $KMenu_types from "./islands/KMenu/types.ts";
|
||||||
import * as $KMenuButton from "./islands/KMenuButton.tsx";
|
import * as $KMenuButton from "./islands/KMenuButton.tsx";
|
||||||
import * as $Link from "./islands/Link.tsx";
|
import * as $Link from "./islands/Link.tsx";
|
||||||
@@ -82,7 +91,12 @@ const manifest = {
|
|||||||
"./routes/api/auth/callback.ts": $api_auth_callback,
|
"./routes/api/auth/callback.ts": $api_auth_callback,
|
||||||
"./routes/api/auth/login.ts": $api_auth_login,
|
"./routes/api/auth/login.ts": $api_auth_login,
|
||||||
"./routes/api/auth/logout.ts": $api_auth_logout,
|
"./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/cache.ts": $api_cache,
|
||||||
|
"./routes/api/hardcover/query.ts": $api_hardcover_query,
|
||||||
"./routes/api/images/index.ts": $api_images_index,
|
"./routes/api/images/index.ts": $api_images_index,
|
||||||
"./routes/api/index.ts": $api_index,
|
"./routes/api/index.ts": $api_index,
|
||||||
"./routes/api/logs.ts": $api_logs,
|
"./routes/api/logs.ts": $api_logs,
|
||||||
@@ -107,6 +121,8 @@ const manifest = {
|
|||||||
"./routes/api/tmdb/query.ts": $api_tmdb_query,
|
"./routes/api/tmdb/query.ts": $api_tmdb_query,
|
||||||
"./routes/articles/[name].tsx": $articles_name_,
|
"./routes/articles/[name].tsx": $articles_name_,
|
||||||
"./routes/articles/index.tsx": $articles_index,
|
"./routes/articles/index.tsx": $articles_index,
|
||||||
|
"./routes/books/[name].tsx": $books_name_,
|
||||||
|
"./routes/books/index.tsx": $books_index,
|
||||||
"./routes/index.tsx": $index,
|
"./routes/index.tsx": $index,
|
||||||
"./routes/movies/[name].tsx": $movies_name_,
|
"./routes/movies/[name].tsx": $movies_name_,
|
||||||
"./routes/movies/index.tsx": $movies_index,
|
"./routes/movies/index.tsx": $movies_index,
|
||||||
@@ -126,6 +142,7 @@ const manifest = {
|
|||||||
$KMenu_commands_add_series_infos,
|
$KMenu_commands_add_series_infos,
|
||||||
"./islands/KMenu/commands/create_article.ts":
|
"./islands/KMenu/commands/create_article.ts":
|
||||||
$KMenu_commands_create_article,
|
$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_movie.ts": $KMenu_commands_create_movie,
|
||||||
"./islands/KMenu/commands/create_recipe.ts": $KMenu_commands_create_recipe,
|
"./islands/KMenu/commands/create_recipe.ts": $KMenu_commands_create_recipe,
|
||||||
"./islands/KMenu/commands/create_recommendations.ts":
|
"./islands/KMenu/commands/create_recommendations.ts":
|
||||||
@@ -133,6 +150,8 @@ const manifest = {
|
|||||||
"./islands/KMenu/commands/create_series.ts": $KMenu_commands_create_series,
|
"./islands/KMenu/commands/create_series.ts": $KMenu_commands_create_series,
|
||||||
"./islands/KMenu/commands/enhance_article_infos.ts":
|
"./islands/KMenu/commands/enhance_article_infos.ts":
|
||||||
$KMenu_commands_enhance_article_infos,
|
$KMenu_commands_enhance_article_infos,
|
||||||
|
"./islands/KMenu/commands/enhance_book_infos.ts":
|
||||||
|
$KMenu_commands_enhance_book_infos,
|
||||||
"./islands/KMenu/types.ts": $KMenu_types,
|
"./islands/KMenu/types.ts": $KMenu_types,
|
||||||
"./islands/KMenuButton.tsx": $KMenuButton,
|
"./islands/KMenuButton.tsx": $KMenuButton,
|
||||||
"./islands/Link.tsx": $Link,
|
"./islands/Link.tsx": $Link,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { createNewSeries } from "@islands/KMenu/commands/create_series.ts";
|
|||||||
import { updateAllRecommendations } from "@islands/KMenu/commands/create_recommendations.ts";
|
import { updateAllRecommendations } from "@islands/KMenu/commands/create_recommendations.ts";
|
||||||
import { createNewRecipe } from "@islands/KMenu/commands/create_recipe.ts";
|
import { createNewRecipe } from "@islands/KMenu/commands/create_recipe.ts";
|
||||||
import { enhanceArticleInfo } from "@islands/KMenu/commands/enhance_article_infos.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> = {
|
export const menus: Record<string, Menu> = {
|
||||||
main: {
|
main: {
|
||||||
@@ -74,11 +76,13 @@ export const menus: Record<string, Menu> = {
|
|||||||
},
|
},
|
||||||
addSeriesInfo,
|
addSeriesInfo,
|
||||||
createNewArticle,
|
createNewArticle,
|
||||||
|
createNewBook,
|
||||||
createNewMovie,
|
createNewMovie,
|
||||||
createNewSeries,
|
createNewSeries,
|
||||||
createNewRecipe,
|
createNewRecipe,
|
||||||
addMovieInfos,
|
addMovieInfos,
|
||||||
enhanceArticleInfo,
|
enhanceArticleInfo,
|
||||||
|
enhanceBookInfo,
|
||||||
// updateAllRecommendations,
|
// updateAllRecommendations,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
134
islands/KMenu/commands/create_book.ts
Normal file
134
islands/KMenu/commands/create_book.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
41
islands/KMenu/commands/enhance_book_infos.ts
Normal file
41
islands/KMenu/commands/enhance_book_infos.ts
Normal 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"));
|
||||||
|
},
|
||||||
|
};
|
||||||
140
lib/hardcover.ts
Normal file
140
lib/hardcover.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ export const BaseFileSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const makeContentSchema = <
|
const makeContentSchema = <
|
||||||
TName extends "Article" | "Review" | "Recipe",
|
TName extends "Article" | "Review" | "Recipe" | "Book",
|
||||||
TShape extends z.ZodRawShape,
|
TShape extends z.ZodRawShape,
|
||||||
>(
|
>(
|
||||||
name: TName,
|
name: TName,
|
||||||
@@ -55,6 +55,9 @@ export const ArticleContentSchema = makeContentSchema("Article", {
|
|||||||
|
|
||||||
export const ReviewContentSchema = makeContentSchema("Review", {
|
export const ReviewContentSchema = makeContentSchema("Review", {
|
||||||
tmdbId: z.number().optional(),
|
tmdbId: z.number().optional(),
|
||||||
|
headline: z.string().optional(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
bookBody: z.string().optional(),
|
||||||
link: z.string().optional(),
|
link: z.string().optional(),
|
||||||
reviewRating: ReviewRatingSchema.optional(),
|
reviewRating: ReviewRatingSchema.optional(),
|
||||||
reviewBody: z.string().optional(),
|
reviewBody: z.string().optional(),
|
||||||
@@ -76,6 +79,17 @@ export const RecipeContentSchema = makeContentSchema("Recipe", {
|
|||||||
url: z.string().optional(),
|
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({
|
export const articleMetadataSchema = z.object({
|
||||||
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
|
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
|
||||||
author: z.union([z.null(), z.string()]).describe("Author 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,
|
content: RecipeContentSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BookSchema = BaseFileSchema.extend({
|
||||||
|
content: BookContentSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const GenericResourceSchema = z.union([
|
export const GenericResourceSchema = z.union([
|
||||||
ArticleSchema,
|
ArticleSchema,
|
||||||
ReviewSchema,
|
ReviewSchema,
|
||||||
RecipeSchema,
|
RecipeSchema,
|
||||||
|
BookSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type Person = z.infer<typeof PersonSchema>;
|
export type Person = z.infer<typeof PersonSchema>;
|
||||||
@@ -122,6 +141,10 @@ export type RecipeResource = z.infer<typeof RecipeSchema> & {
|
|||||||
image?: typeof imageTable.$inferSelect;
|
image?: typeof imageTable.$inferSelect;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BookResource = z.infer<typeof BookSchema> & {
|
||||||
|
image?: typeof imageTable.$inferSelect;
|
||||||
|
};
|
||||||
|
|
||||||
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
|
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
|
||||||
image?: typeof imageTable.$inferSelect;
|
image?: typeof imageTable.$inferSelect;
|
||||||
};
|
};
|
||||||
@@ -136,5 +159,8 @@ export function getNameOfResource(res: GenericResource): string {
|
|||||||
if (res.content?._type === "Recipe" && res.content.name) {
|
if (res.content?._type === "Recipe" && res.content.name) {
|
||||||
return res.content.name;
|
return res.content.name;
|
||||||
}
|
}
|
||||||
|
if (res.content?._type === "Book" && res.content.headline) {
|
||||||
|
return res.content.headline;
|
||||||
|
}
|
||||||
return "Unnamed Resource";
|
return "Unnamed Resource";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,9 @@ export const resources = {
|
|||||||
name: "Series",
|
name: "Series",
|
||||||
link: "/series",
|
link: "/series",
|
||||||
},
|
},
|
||||||
|
"book": {
|
||||||
|
emoji: "Bookmark Tabs.png",
|
||||||
|
name: "Books",
|
||||||
|
link: "/books",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
75
routes/api/books/[name].ts
Normal file
75
routes/api/books/[name].ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
242
routes/api/books/create/index.ts
Normal file
242
routes/api/books/create/index.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
244
routes/api/books/enhance/[name].ts
Normal file
244
routes/api/books/enhance/[name].ts
Normal 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
10
routes/api/books/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
28
routes/api/hardcover/query.ts
Normal file
28
routes/api/hardcover/query.ts
Normal 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,
|
||||||
|
};
|
||||||
122
routes/books/[name].tsx
Normal file
122
routes/books/[name].tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||||
|
import { MainLayout } from "@components/layouts/main.tsx";
|
||||||
|
import { KMenu } from "@islands/KMenu.tsx";
|
||||||
|
import { YoutubePlayer } from "@components/Youtube.tsx";
|
||||||
|
import { HashTags } from "@components/HashTags.tsx";
|
||||||
|
import { isYoutubeLink } from "@lib/string.ts";
|
||||||
|
import { removeImage, renderMarkdown } from "@lib/markdown.ts";
|
||||||
|
import { RedirectSearchHandler } from "@islands/Search.tsx";
|
||||||
|
import PageHero from "@components/PageHero.tsx";
|
||||||
|
import { Star } from "@components/Stars.tsx";
|
||||||
|
import { MetaTags } from "@components/MetaTags.tsx";
|
||||||
|
import { fetchResource } from "@lib/marka/index.ts";
|
||||||
|
import { BookResource } from "@lib/marka/schema.ts";
|
||||||
|
import { parseRating } from "@lib/helpers.ts";
|
||||||
|
|
||||||
|
export const handler: Handlers<{ book: BookResource; session: unknown }> =
|
||||||
|
{
|
||||||
|
async GET(_, ctx) {
|
||||||
|
const book = await fetchResource<BookResource>(
|
||||||
|
`books/${ctx.params.name}.md`,
|
||||||
|
);
|
||||||
|
if (!book) {
|
||||||
|
return ctx.renderNotFound();
|
||||||
|
}
|
||||||
|
return ctx.render({ book, session: ctx.state.session });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Greet(
|
||||||
|
props: PageProps<
|
||||||
|
{ book: BookResource; session: Record<string, string> }
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const { book, session } = props.data;
|
||||||
|
|
||||||
|
const { author, datePublished, reviewRating, bookBody = "", reviewBody } =
|
||||||
|
book?.content || {};
|
||||||
|
|
||||||
|
const bookContent = renderMarkdown(
|
||||||
|
removeImage(bookBody, book.image?.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewContent = reviewBody
|
||||||
|
? renderMarkdown(removeImage(reviewBody, book.image?.url))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rating = reviewRating?.ratingValue &&
|
||||||
|
parseRating(reviewRating.ratingValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout
|
||||||
|
url={props.url}
|
||||||
|
title={`Book > ${book.content.headline}`}
|
||||||
|
context={book}
|
||||||
|
>
|
||||||
|
<RedirectSearchHandler />
|
||||||
|
<KMenu type="main" context={book} />
|
||||||
|
<MetaTags resource={book} />
|
||||||
|
|
||||||
|
<PageHero
|
||||||
|
image={book.image?.url}
|
||||||
|
thumbhash={book.image?.thumbhash}
|
||||||
|
>
|
||||||
|
<PageHero.Header>
|
||||||
|
<PageHero.BackLink href="/books" />
|
||||||
|
{session && (
|
||||||
|
<PageHero.EditLink
|
||||||
|
href={`https://notes.max-richter.dev/resources/books/${book.name}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageHero.Header>
|
||||||
|
<PageHero.Footer>
|
||||||
|
<PageHero.Title link={book.content.url}>
|
||||||
|
{book.content.headline}
|
||||||
|
</PageHero.Title>
|
||||||
|
<PageHero.Subline
|
||||||
|
entries={[
|
||||||
|
author?.name && {
|
||||||
|
title: author.name,
|
||||||
|
href: `/?q=${encodeURIComponent(author?.name)}`,
|
||||||
|
},
|
||||||
|
datePublished?.toString(),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{rating && <Star rating={rating} />}
|
||||||
|
</PageHero.Subline>
|
||||||
|
</PageHero.Footer>
|
||||||
|
</PageHero>
|
||||||
|
{book.content?.keywords?.length && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<HashTags tags={book.content.keywords} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="px-8 text-white mt-10">
|
||||||
|
{(book.content.url && isYoutubeLink(book.content.url)) && (
|
||||||
|
<YoutubePlayer link={book.content.url} />
|
||||||
|
)}
|
||||||
|
<pre
|
||||||
|
class="whitespace-break-spaces markdown-body"
|
||||||
|
data-color-mode="dark"
|
||||||
|
data-dark-theme="dark"
|
||||||
|
// deno-lint-ignore react-no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: bookContent || "" }}
|
||||||
|
/>
|
||||||
|
{reviewContent && (
|
||||||
|
<>
|
||||||
|
<h2 class="text-4xl font-bold mb-4 mt-8">Review</h2>
|
||||||
|
<pre
|
||||||
|
class="whitespace-break-spaces markdown-body"
|
||||||
|
data-color-mode="dark"
|
||||||
|
data-dark-theme="dark"
|
||||||
|
// deno-lint-ignore react-no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: reviewContent }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
routes/books/index.tsx
Normal file
62
routes/books/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||||
|
import { MainLayout } from "@components/layouts/main.tsx";
|
||||||
|
import { type BookResource, GenericResource } from "@lib/marka/schema.ts";
|
||||||
|
import { KMenu } from "@islands/KMenu.tsx";
|
||||||
|
import { Grid } from "@components/Grid.tsx";
|
||||||
|
import { IconArrowLeft } from "@components/icons.tsx";
|
||||||
|
import { RedirectSearchHandler } from "@islands/Search.tsx";
|
||||||
|
import { parseResourceUrl, searchResource } from "@lib/search.ts";
|
||||||
|
import { ResourceCard } from "@components/Card.tsx";
|
||||||
|
import { Link } from "@islands/Link.tsx";
|
||||||
|
import { listResources } from "@lib/marka/index.ts";
|
||||||
|
|
||||||
|
export const handler: Handlers<
|
||||||
|
{ books: BookResource[] | null; searchResults?: GenericResource[] }
|
||||||
|
> = {
|
||||||
|
async GET(req, ctx) {
|
||||||
|
const books = await listResources<BookResource>("books");
|
||||||
|
const searchParams = parseResourceUrl(req.url);
|
||||||
|
const searchResults = searchParams &&
|
||||||
|
await searchResource({ ...searchParams, types: ["books"] });
|
||||||
|
return ctx.render({ books, searchResults });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Greet(
|
||||||
|
props: PageProps<
|
||||||
|
{ books: BookResource[] | null; searchResults: GenericResource[] }
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const { books, searchResults } = props.data;
|
||||||
|
return (
|
||||||
|
<MainLayout
|
||||||
|
url={props.url}
|
||||||
|
title="Books"
|
||||||
|
context={{ type: "books" }}
|
||||||
|
searchResults={searchResults}
|
||||||
|
>
|
||||||
|
<header class="flex gap-4 items-center mb-5 md:hidden">
|
||||||
|
<Link
|
||||||
|
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<IconArrowLeft class="w-5 h-5" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h3 class="text-2xl text-white font-light">📚 Books</h3>
|
||||||
|
</header>
|
||||||
|
<RedirectSearchHandler />
|
||||||
|
<KMenu type="main" context={{ type: "books" }} />
|
||||||
|
<Grid>
|
||||||
|
{books?.map((doc, i) => (
|
||||||
|
<ResourceCard
|
||||||
|
key={doc.name || i}
|
||||||
|
sublink="books"
|
||||||
|
res={doc}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user