feat: initial refactor to use marka as backend
This commit is contained in:
149
lib/crud.ts
149
lib/crud.ts
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
getDocuments,
|
||||
transformDocument,
|
||||
} from "@lib/documents.ts";
|
||||
import { Root } from "https://esm.sh/remark-frontmatter@4.0.1";
|
||||
import { GenericResource } from "@lib/types.ts";
|
||||
import { parseRating } from "@lib/helpers.ts";
|
||||
import { isLocalImage } from "@lib/string.ts";
|
||||
import { SILVERBULLET_SERVER } from "@lib/env.ts";
|
||||
import { imageTable } from "@lib/db/schema.ts";
|
||||
import { db } from "@lib/db/sqlite.ts";
|
||||
import { eq } from "drizzle-orm/sql";
|
||||
import { createCache } from "@lib/cache.ts";
|
||||
|
||||
export async function addThumbnailToResource<T extends GenericResource>(
|
||||
res: T,
|
||||
): Promise<T> {
|
||||
if (!res?.meta?.image) return res;
|
||||
|
||||
const imageUrl = isLocalImage(res.meta.image)
|
||||
? `${SILVERBULLET_SERVER}/${res.meta.image}`
|
||||
: res.meta.image;
|
||||
|
||||
const image = await db.select().from(imageTable)
|
||||
.where(eq(imageTable.url, imageUrl))
|
||||
.limit(1)
|
||||
.then((images) => images[0]);
|
||||
|
||||
if (image) {
|
||||
return {
|
||||
...res,
|
||||
meta: {
|
||||
...res.meta,
|
||||
average: image.average,
|
||||
thumbnail: image.blurhash,
|
||||
},
|
||||
};
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
type SortType = "rating" | "date" | "name" | "author";
|
||||
|
||||
function sortFunction<T extends GenericResource>(sortType: SortType) {
|
||||
return (a: T, b: T) => {
|
||||
switch (sortType) {
|
||||
case "rating":
|
||||
return parseRating(a.meta?.rating || 0) >
|
||||
parseRating(b.meta?.rating || 0)
|
||||
? -1
|
||||
: 1;
|
||||
case "date":
|
||||
return (a.meta?.date || 0) > (b.meta?.date || 0) ? -1 : 1;
|
||||
case "name":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "author":
|
||||
return a.meta?.author?.localeCompare(b.meta?.author || "") || 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCrud<T extends GenericResource>(
|
||||
{ prefix, parse, render, hasThumbnails = false }: {
|
||||
prefix: string;
|
||||
hasThumbnails?: boolean;
|
||||
render?: (doc: T) => string;
|
||||
parse: (doc: string, id: string) => T;
|
||||
},
|
||||
) {
|
||||
const cache = createCache<T>(`crud/${prefix}`, { expires: 60 * 1000 });
|
||||
|
||||
function pathFromId(id: string) {
|
||||
return `${prefix}${id.replaceAll(":", "")}.md`;
|
||||
}
|
||||
|
||||
async function read(id: string) {
|
||||
const path = pathFromId(id);
|
||||
|
||||
const content = await getDocument(path);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = parse(content, id);
|
||||
|
||||
if (hasThumbnails) {
|
||||
parsed = await addThumbnailToResource(parsed);
|
||||
}
|
||||
const doc = { ...parsed, content };
|
||||
|
||||
return doc;
|
||||
}
|
||||
function create(id: string, content: string | ArrayBuffer | T) {
|
||||
const path = pathFromId(id);
|
||||
cache.set("all", undefined);
|
||||
if (
|
||||
typeof content === "string" || content instanceof ArrayBuffer
|
||||
) {
|
||||
return createDocument(path, content);
|
||||
}
|
||||
|
||||
if (render) {
|
||||
const rendered = render(content);
|
||||
return createDocument(path, rendered);
|
||||
}
|
||||
|
||||
throw new Error("No renderer defined for " + prefix + " CRUD");
|
||||
}
|
||||
|
||||
async function update(id: string, updater: (r: Root) => Root) {
|
||||
const path = pathFromId(id);
|
||||
const content = await getDocument(path);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const newDoc = transformDocument(content, updater);
|
||||
await createDocument(path, newDoc);
|
||||
}
|
||||
|
||||
async function readAll({ sort = "rating" }: { sort?: SortType } = {}) {
|
||||
if (cache.has("all")) {
|
||||
return cache.get("all") as unknown as T[];
|
||||
}
|
||||
const allDocuments = await getDocuments();
|
||||
const parsed = (await Promise.all(
|
||||
allDocuments.filter((d) => {
|
||||
return d.name.startsWith(prefix) &&
|
||||
d.contentType === "text/markdown" &&
|
||||
!d.name.endsWith("index.md");
|
||||
}).map((doc) => {
|
||||
const id = doc.name.replace(prefix, "").replace(/\.md$/, "");
|
||||
return read(id);
|
||||
}),
|
||||
)).sort(sortFunction<T>(sort)).filter((v) => !!v);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
readAll,
|
||||
create,
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { drizzle } from "drizzle-orm/libsql/node";
|
||||
import { DATA_DIR } from "@lib/env.ts";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import path from "node:path";
|
||||
|
||||
const DB_FILE = "file:" + path.resolve(DATA_DIR, "db.sqlite");
|
||||
|
||||
161
lib/documents.ts
161
lib/documents.ts
@@ -1,19 +1,7 @@
|
||||
import { unified } from "https://esm.sh/unified@10.1.2";
|
||||
import { render } from "gfm";
|
||||
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";
|
||||
import remarkParse from "https://esm.sh/remark-parse@10.0.2";
|
||||
import remarkStringify from "https://esm.sh/remark-stringify@10.0.3";
|
||||
import remarkFrontmatter, {
|
||||
Root,
|
||||
} from "https://esm.sh/remark-frontmatter@4.0.1";
|
||||
import { SILVERBULLET_SERVER } from "@lib/env.ts";
|
||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||
import { createLogger } from "@lib/log/index.ts";
|
||||
import { db } from "@lib/db/sqlite.ts";
|
||||
import { documentTable } from "@lib/db/schema.ts";
|
||||
import { eq } from "drizzle-orm/sql";
|
||||
|
||||
export type Document = {
|
||||
name: string;
|
||||
@@ -24,114 +12,6 @@ export type Document = {
|
||||
perm: string;
|
||||
};
|
||||
|
||||
const log = createLogger("documents");
|
||||
|
||||
export async function getDocuments(): Promise<Document[]> {
|
||||
let documents = await db.select().from(documentTable).all();
|
||||
if (documents.length) return documents;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Accept", "application/json");
|
||||
headers.append("X-Sync-Mode", "true");
|
||||
log.debug("fetching all documents");
|
||||
const response = await fetch(`${SILVERBULLET_SERVER}/index.json`, {
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
documents = await response.json();
|
||||
await db.delete(documentTable);
|
||||
await db.insert(documentTable).values(documents);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
export function createDocument(
|
||||
name: string,
|
||||
content: string | ArrayBuffer,
|
||||
mediaType?: string,
|
||||
) {
|
||||
const headers = new Headers();
|
||||
|
||||
if (mediaType) {
|
||||
headers.append("Content-Type", mediaType);
|
||||
}
|
||||
|
||||
log.info("creating document", { name });
|
||||
|
||||
if (typeof content === "string") {
|
||||
updateDocument(name, content).catch(log.error);
|
||||
}
|
||||
|
||||
return fetch(SILVERBULLET_SERVER + "/" + name, {
|
||||
body: content,
|
||||
method: "PUT",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDocument(name: string) {
|
||||
log.debug("fetching document", { name });
|
||||
const headers = new Headers();
|
||||
headers.append("X-Sync-Mode", "true");
|
||||
const response = await fetch(SILVERBULLET_SERVER + "/" + name, { headers });
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function getDocument(name: string): Promise<string | undefined> {
|
||||
const documents = await db.select().from(documentTable).where(
|
||||
eq(documentTable.name, name),
|
||||
).limit(1);
|
||||
// This updates the document in the background
|
||||
fetchDocument(name).then((content) => {
|
||||
if (content) {
|
||||
updateDocument(name, content);
|
||||
} else {
|
||||
db.delete(documentTable).where(eq(documentTable.name, name));
|
||||
}
|
||||
}).catch(
|
||||
log.error,
|
||||
);
|
||||
if (documents[0]?.content) return documents[0].content;
|
||||
|
||||
const text = await fetchDocument(name);
|
||||
if (!text) {
|
||||
db.delete(documentTable).where(eq(documentTable.name, name));
|
||||
return;
|
||||
}
|
||||
await updateDocument(name, text);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function updateDocument(name: string, content: string) {
|
||||
return db.update(documentTable).set({
|
||||
content,
|
||||
}).where(eq(documentTable.name, name)).run();
|
||||
}
|
||||
|
||||
export function transformDocument(input: string, cb: (r: Root) => Root) {
|
||||
const out = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkFrontmatter, ["yaml"])
|
||||
.use(() => (tree) => {
|
||||
return cb(tree);
|
||||
})
|
||||
.use(remarkStringify)
|
||||
.processSync(input);
|
||||
|
||||
return fixRenderedMarkdown(String(out));
|
||||
}
|
||||
|
||||
export function parseDocument(doc: string) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkFrontmatter, ["yaml", "toml"])
|
||||
.parse(doc);
|
||||
}
|
||||
|
||||
function removeFrontmatter(doc: string) {
|
||||
if (doc.trim().startsWith("---")) {
|
||||
return doc.trim().split("---").filter((s) => s.length).slice(1).join("---");
|
||||
@@ -160,42 +40,15 @@ export function removeImage(doc: string, imageUrl?: string) {
|
||||
|
||||
export function renderMarkdown(doc: string) {
|
||||
return render(removeFrontmatter(doc), {
|
||||
baseUrl: SILVERBULLET_SERVER,
|
||||
baseUrl: "https://max-richter.dev",
|
||||
allowMath: true,
|
||||
});
|
||||
}
|
||||
|
||||
export type ParsedDocument = ReturnType<typeof parseDocument>;
|
||||
export type DocumentChild = ParsedDocument["children"][number];
|
||||
|
||||
export function findRangeOfChildren(children: DocumentChild[]) {
|
||||
const firstChild = children[0];
|
||||
const lastChild = children.length > 1
|
||||
? children[children.length - 1]
|
||||
: firstChild;
|
||||
|
||||
const start = firstChild.position?.start.offset;
|
||||
const end = lastChild.position?.end.offset;
|
||||
|
||||
if (typeof start !== "number" || typeof end !== "number") return;
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
export function getTextOfRange(children: DocumentChild[], text: string) {
|
||||
if (!children || children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = findRangeOfChildren(children);
|
||||
if (!range) return;
|
||||
return text.substring(range[0], range[1]);
|
||||
}
|
||||
|
||||
export function getTextOfChild(child: DocumentChild): string | undefined {
|
||||
if ("value" in child) return child.value;
|
||||
if ("children" in child) {
|
||||
return getTextOfChild(child.children[0]);
|
||||
}
|
||||
return;
|
||||
export function createDocument(
|
||||
path: string,
|
||||
entry: string,
|
||||
mimetype = "image/jpeg",
|
||||
) {
|
||||
console.log("creating", { path, entry, mimetype });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ export const PROXY_SERVER = Deno.env.get("PROXY_SERVER");
|
||||
export const PROXY_USERNAME = Deno.env.get("PROXY_USERNAME");
|
||||
export const PROXY_PASSWORD = Deno.env.get("PROXY_PASSWORD");
|
||||
|
||||
export const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
|
||||
export const TMDB_API_KEY = Deno.env.get("TMDB_API_KEY");
|
||||
export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
|
||||
export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
|
||||
@@ -15,8 +14,6 @@ export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID")!;
|
||||
export const GITEA_CLIENT_SECRET = Deno.env.get("GITEA_CLIENT_SECRET");
|
||||
export const GITEA_REDIRECT_URL = Deno.env.get("GITEA_REDIRECT_URL");
|
||||
|
||||
export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "dev.db";
|
||||
|
||||
const duration = Deno.env.get("SESSION_DURATION");
|
||||
export const SESSION_DURATION = duration ? +duration : (60 * 60 * 24);
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||
|
||||
export function parseRating(rating: string | number) {
|
||||
if (typeof rating === "string") {
|
||||
return [...rating.matchAll(/⭐/)].length;
|
||||
return [...rating.matchAll(/⭐/g)].length;
|
||||
}
|
||||
return rating;
|
||||
}
|
||||
|
||||
@@ -17,25 +17,31 @@ export const IngredientGroupSchema = z.object({
|
||||
export type IngredientGroup = z.infer<typeof IngredientGroupSchema>;
|
||||
|
||||
const recipeSchema = z.object({
|
||||
title: z.string().describe(
|
||||
"Title of the Recipe, without the name of the website or author",
|
||||
),
|
||||
image: z.string().describe("URL of the main image of the recipe"),
|
||||
author: z.string().describe("author of the Recipe (optional)"),
|
||||
description: z.string().describe("Optional, short description of the recipe"),
|
||||
ingredients: z.array(z.union([IngredientSchema, IngredientGroupSchema]))
|
||||
.describe("List of ingredients"),
|
||||
instructions: z.array(z.string()).describe("List of instructions"),
|
||||
servings: z.number().describe("Amount of Portions"),
|
||||
prepTime: z.number().describe("Preparation time in minutes"),
|
||||
cookTime: z.number().describe("Cooking time in minutes"),
|
||||
totalTime: z.number().describe("Total time in minutes"),
|
||||
tags: z.array(z.string()).describe(
|
||||
"List of tags (e.g., ['vegan', 'dessert'])",
|
||||
),
|
||||
notes: z.array(z.string()).describe("Optional notes about the recipe"),
|
||||
name: z.string(),
|
||||
content: z.object({
|
||||
_type: z.literal("Recipe"),
|
||||
name: z.string().describe(
|
||||
"Title of the Recipe, without the name of the website or author",
|
||||
),
|
||||
description: z.string().describe(
|
||||
"Optional, short description of the recipe",
|
||||
),
|
||||
image: z.string().describe("URL of the main image of the recipe"),
|
||||
author: z.object({
|
||||
_type: z.literal("Person"),
|
||||
name: z.string().describe("author of the Recipe (optional)"),
|
||||
}),
|
||||
recipeEngredient: z.array(z.string())
|
||||
.describe("List of ingredients"),
|
||||
recipeInstructions: z.array(z.string()).describe("List of instructions"),
|
||||
recipeYield: z.number().describe("Amount of Portions"),
|
||||
prepTime: z.number().describe("Preparation time in minutes"),
|
||||
cookTime: z.number().describe("Cooking time in minutes"),
|
||||
}),
|
||||
});
|
||||
|
||||
export type Recipe = z.infer<typeof recipeSchema>;
|
||||
|
||||
const noRecipeSchema = z.object({
|
||||
errorMessages: z.array(z.string()).describe(
|
||||
"List of error messages, if no recipe was found",
|
||||
@@ -46,12 +52,13 @@ export const recipeResponseSchema = z.union([recipeSchema, noRecipeSchema]);
|
||||
|
||||
export function isValidRecipe(
|
||||
recipe:
|
||||
| { ingredients?: unknown[]; instructions?: string[]; name?: string }
|
||||
| Recipe
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return recipe?.ingredients?.length && recipe.ingredients.length > 1 &&
|
||||
recipe?.instructions?.length &&
|
||||
return recipe?.content?.recipeIngredient?.length &&
|
||||
recipe?.content?.recipeIngredient.length > 1 &&
|
||||
recipe?.content?.recipeInstructions?.length &&
|
||||
recipe.name?.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { parseDocument } from "@lib/documents.ts";
|
||||
import { parse, stringify } from "@std/yaml";
|
||||
import { createCrud } from "@lib/crud.ts";
|
||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
type: "article";
|
||||
@@ -21,88 +15,3 @@ export type Article = {
|
||||
rating?: number;
|
||||
};
|
||||
};
|
||||
|
||||
function renderArticle(article: Article) {
|
||||
const meta = article.meta;
|
||||
if ("date" in meta) {
|
||||
meta.date = formatDate(meta.date);
|
||||
}
|
||||
|
||||
return fixRenderedMarkdown(`${meta
|
||||
? `---
|
||||
${stringify(meta)}
|
||||
---`
|
||||
: `---
|
||||
---`
|
||||
}
|
||||
# ${article.name}
|
||||
${article.tags.map((t) => `#${t}`).join(" ")}
|
||||
${article.content}
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArticle(original: string, id: string): Article {
|
||||
const doc = parseDocument(original);
|
||||
|
||||
let meta = {} as Article["meta"];
|
||||
let name = "";
|
||||
|
||||
const range = [Infinity, -Infinity];
|
||||
|
||||
for (const child of doc.children) {
|
||||
if (child.type === "yaml") {
|
||||
try {
|
||||
meta = parse(child.value) as Article["meta"];
|
||||
} catch (err) {
|
||||
console.log("Error parsing YAML", err);
|
||||
console.log("YAML:", child.value);
|
||||
}
|
||||
|
||||
if (meta["rating"] && typeof meta["rating"] === "string") {
|
||||
meta.rating = [...meta.rating?.matchAll("⭐")].length;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
child.type === "heading" && child.depth === 1 && !name &&
|
||||
child.children.length === 1 && child.children[0].type === "text"
|
||||
) {
|
||||
name = child.children[0].value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const start = child.position?.start.offset || Infinity;
|
||||
const end = child.position?.end.offset || -Infinity;
|
||||
if (start < range[0]) range[0] = start;
|
||||
if (end > range[1]) range[1] = end;
|
||||
}
|
||||
}
|
||||
|
||||
let content = original.slice(range[0], range[1]);
|
||||
const tags = extractHashTags(content);
|
||||
for (const tag of tags) {
|
||||
content = content.replace("#" + tag, "");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "article",
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
content,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
const crud = createCrud<Article>({
|
||||
prefix: "Media/articles/",
|
||||
parse: parseArticle,
|
||||
render: renderArticle,
|
||||
hasThumbnails: true,
|
||||
});
|
||||
export const getAllArticles = crud.readAll;
|
||||
export const getArticle = crud.read;
|
||||
export const createArticle = crud.create;
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { parseDocument } from "@lib/documents.ts";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { createCrud } from "@lib/crud.ts";
|
||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||
|
||||
export type Movie = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,103 +15,3 @@ export type Movie = {
|
||||
rating: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function renderMovie(movie: Movie) {
|
||||
const meta = movie.meta;
|
||||
if ("date" in meta && typeof meta.date !== "string") {
|
||||
meta.date = formatDate(meta.date) as unknown as Date;
|
||||
}
|
||||
|
||||
delete meta.thumbnail;
|
||||
delete meta.average;
|
||||
|
||||
const movieImage = ``;
|
||||
|
||||
return fixRenderedMarkdown(`${
|
||||
meta
|
||||
? `---
|
||||
${stringify(meta)}
|
||||
---`
|
||||
: `---
|
||||
---`
|
||||
}
|
||||
# ${movie.name}
|
||||
${
|
||||
// So we do not add a new image to the description everytime we render
|
||||
(movie.meta.image && !movie.description.includes(movieImage))
|
||||
? movieImage
|
||||
: ""}
|
||||
${movie.tags.map((t) => `#${t}`).join(" ")}
|
||||
${movie.description}
|
||||
`);
|
||||
}
|
||||
|
||||
export function parseMovie(original: string, id: string): Movie {
|
||||
const doc = parseDocument(original);
|
||||
|
||||
let meta = {} as Movie["meta"];
|
||||
let name = "";
|
||||
|
||||
const range = [Infinity, -Infinity];
|
||||
|
||||
for (const child of doc.children) {
|
||||
if (child.type === "yaml") {
|
||||
try {
|
||||
meta = (parse(child.value) || {}) as Movie["meta"];
|
||||
} catch (_) {
|
||||
// ignore here
|
||||
}
|
||||
|
||||
if (meta["rating"] && typeof meta["rating"] === "string") {
|
||||
meta.rating = [...meta.rating?.matchAll("⭐")].length;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
child.type === "heading" && child.depth === 1 && !name &&
|
||||
child.children.length === 1 && child.children[0].type === "text"
|
||||
) {
|
||||
name = child.children[0].value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const start = child.position?.start.offset || Infinity;
|
||||
const end = child.position?.end.offset || -Infinity;
|
||||
if (start < range[0]) range[0] = start;
|
||||
if (end > range[1]) range[1] = end;
|
||||
}
|
||||
}
|
||||
|
||||
let description = original.slice(range[0], range[1]);
|
||||
const tags = extractHashTags(description);
|
||||
for (const tag of tags) {
|
||||
description = description.replace("#" + tag, "");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "movie",
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
description,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
const crud = createCrud<Movie>({
|
||||
prefix: "Media/movies/",
|
||||
parse: parseMovie,
|
||||
render: renderMovie,
|
||||
hasThumbnails: true,
|
||||
});
|
||||
|
||||
export const getMovie = async (id: string) => {
|
||||
const movie = await crud.read(id);
|
||||
return movie;
|
||||
};
|
||||
|
||||
export const getAllMovies = crud.readAll;
|
||||
export const createMovie = crud.create;
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import {
|
||||
type DocumentChild,
|
||||
getTextOfRange,
|
||||
parseDocument,
|
||||
} from "@lib/documents.ts";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { createCrud } from "@lib/crud.ts";
|
||||
import { extractHashTags } from "@lib/string.ts";
|
||||
import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
|
||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||
import { parseIngredients } from "@lib/parseIngredient.ts";
|
||||
|
||||
export type Recipe = {
|
||||
type: "recipe";
|
||||
id: string;
|
||||
@@ -31,179 +19,3 @@ export type Recipe = {
|
||||
thumbnail?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function extractSteps(
|
||||
content: string,
|
||||
seperator: RegExp = /\n(?=\d+\.)/g,
|
||||
): string[] {
|
||||
const steps = content.split(seperator).map((step) => {
|
||||
const match = step.match(/^(\d+)\.\s*(.*)/);
|
||||
if (match) return match[2];
|
||||
return step;
|
||||
}).filter((step) => !!step);
|
||||
return steps as string[];
|
||||
}
|
||||
|
||||
export function parseRecipe(original: string, id: string): Recipe {
|
||||
const doc = parseDocument(original);
|
||||
|
||||
let name = "";
|
||||
let meta: Recipe["meta"] = {};
|
||||
|
||||
const groups: DocumentChild[][] = [];
|
||||
let group: DocumentChild[] = [];
|
||||
for (const child of doc.children) {
|
||||
if (child.type === "yaml") {
|
||||
try {
|
||||
meta = parse(child.value) as Recipe["meta"];
|
||||
} catch (err) {
|
||||
console.log("Error parsing YAML", err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
child.type === "heading" && child.depth === 1 && !name &&
|
||||
child.children.length === 1 && child.children[0].type === "text"
|
||||
) {
|
||||
name = child.children[0].value;
|
||||
continue;
|
||||
}
|
||||
if (child.type === "thematicBreak") {
|
||||
groups.push(group);
|
||||
group = [];
|
||||
continue;
|
||||
}
|
||||
group.push(child);
|
||||
}
|
||||
|
||||
if (group.length) {
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
let description = getTextOfRange(groups[0], original);
|
||||
|
||||
let ingredientsText = getTextOfRange(groups[1], original);
|
||||
if (ingredientsText) {
|
||||
ingredientsText = ingredientsText.replace(/#+\s?Ingredients?/, "");
|
||||
} else {
|
||||
ingredientsText = "";
|
||||
}
|
||||
|
||||
const ingredients = parseIngredients(ingredientsText);
|
||||
|
||||
const instructionText = getTextOfRange(groups[2], original);
|
||||
let instructions = extractSteps(instructionText || "");
|
||||
if (instructions.length <= 1) {
|
||||
const d = extractSteps(instructionText || "", /\n/g);
|
||||
if (d.length > instructions.length) {
|
||||
instructions = d;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = extractHashTags(description || "");
|
||||
if (description) {
|
||||
for (const tag of tags) {
|
||||
description = description.replace("#" + tag, "");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "recipe",
|
||||
id,
|
||||
meta,
|
||||
name,
|
||||
tags,
|
||||
markdown: original,
|
||||
notes: getTextOfRange(groups[3], original)?.split("\n"),
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
function filterUndefinedFromObject<T extends { [key: string]: unknown }>(
|
||||
obj: T,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderRecipe(recipe: Recipe) {
|
||||
const meta = filterUndefinedFromObject(recipe.meta || {});
|
||||
|
||||
// Clean up meta properties
|
||||
delete meta.thumbnail;
|
||||
delete meta.average;
|
||||
|
||||
const recipeImage = meta.image ? `` : "";
|
||||
|
||||
// Format ingredient groups and standalone ingredients
|
||||
const ingredients = recipe.ingredients
|
||||
.map((item) => {
|
||||
if ("items" in item) {
|
||||
return `\n*${item.name}*\n${
|
||||
item.items
|
||||
.map((ing) => {
|
||||
if (ing.quantity && ing.unit) {
|
||||
return `- **${ing.quantity.trim() || ""}${
|
||||
ing.unit.trim() || ""
|
||||
}** ${ing.name}`;
|
||||
}
|
||||
return `- ${ing.name}`;
|
||||
})
|
||||
.join("\n")
|
||||
}`;
|
||||
}
|
||||
if (item.quantity && item.unit) {
|
||||
return `- **${item.quantity?.trim() || ""}${
|
||||
item.unit?.trim() || ""
|
||||
}** ${item.name}`;
|
||||
}
|
||||
|
||||
if (item.quantity) {
|
||||
return `- **${item.quantity}** ${item.name}`;
|
||||
}
|
||||
|
||||
return `- ${item.name}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Format instructions as a numbered list
|
||||
const instructions = recipe.instructions
|
||||
? recipe.instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")
|
||||
: "";
|
||||
|
||||
// Render the final markdown
|
||||
return fixRenderedMarkdown(`${
|
||||
Object.keys(meta).length
|
||||
? `---
|
||||
${stringify(meta)}
|
||||
---`
|
||||
: `---
|
||||
---`
|
||||
}
|
||||
# ${recipe.name}
|
||||
${recipe.meta?.image ? recipeImage : ""}
|
||||
${recipe.tags.map((t) => `#${t.replaceAll(" ", "-")}`).join(" ")}
|
||||
${recipe.description || ""}
|
||||
|
||||
---
|
||||
|
||||
${ingredients ? `## Ingredients\n\n${ingredients}\n\n---\n` : ""}
|
||||
${instructions ? `${instructions}\n\n---` : ""}
|
||||
${recipe.notes?.length ? `\n${recipe.notes.join("\n")}` : ""}
|
||||
`);
|
||||
}
|
||||
|
||||
const crud = createCrud<Recipe>({
|
||||
prefix: `Recipes/`,
|
||||
parse: parseRecipe,
|
||||
render: renderRecipe,
|
||||
hasThumbnails: true,
|
||||
});
|
||||
|
||||
export const getAllRecipes = crud.readAll;
|
||||
export const getRecipe = crud.read;
|
||||
export const updateRecipe = crud.update;
|
||||
export const createRecipe = crud.create;
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { parseDocument } from "@lib/documents.ts";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { createCrud } from "@lib/crud.ts";
|
||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||
|
||||
export type Series = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,99 +15,3 @@ export type Series = {
|
||||
done?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function renderSeries(series: Series) {
|
||||
const meta = series.meta;
|
||||
if ("date" in meta) {
|
||||
meta.date = formatDate(meta.date);
|
||||
}
|
||||
|
||||
delete meta.thumbnail;
|
||||
delete meta.average;
|
||||
|
||||
const movieImage = ``;
|
||||
|
||||
return fixRenderedMarkdown(`${
|
||||
meta
|
||||
? `---
|
||||
${stringify(meta)}
|
||||
---`
|
||||
: `---
|
||||
---`
|
||||
}
|
||||
# ${series.name}
|
||||
${
|
||||
// So we do not add a new image to the description everytime we render
|
||||
(series.meta.image && !series.description.includes(movieImage))
|
||||
? movieImage
|
||||
: ""}
|
||||
${series.tags.map((t) => `#${t}`).join(" ")}
|
||||
${series.description}
|
||||
`);
|
||||
}
|
||||
|
||||
export function parseSeries(original: string, id: string): Series {
|
||||
const doc = parseDocument(original);
|
||||
|
||||
let meta = {} as Series["meta"];
|
||||
let name = "";
|
||||
|
||||
const range = [Infinity, -Infinity];
|
||||
|
||||
for (const child of doc.children) {
|
||||
if (child.type === "yaml") {
|
||||
try {
|
||||
meta = (parse(child.value) || {}) as Series["meta"];
|
||||
} catch (_) {
|
||||
// ignore here
|
||||
}
|
||||
|
||||
if (meta["rating"] && typeof meta["rating"] === "string") {
|
||||
meta.rating = [...meta.rating?.matchAll("⭐")].length;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
child.type === "heading" && child.depth === 1 && !name &&
|
||||
child.children.length === 1 && child.children[0].type === "text"
|
||||
) {
|
||||
name = child.children[0].value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const start = child.position?.start.offset || Infinity;
|
||||
const end = child.position?.end.offset || -Infinity;
|
||||
if (start < range[0]) range[0] = start;
|
||||
if (end > range[1]) range[1] = end;
|
||||
}
|
||||
}
|
||||
|
||||
let description = original.slice(range[0], range[1]);
|
||||
const tags = extractHashTags(description);
|
||||
for (const tag of tags) {
|
||||
description = description.replace("#" + tag, "");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "series",
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
description,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
const crud = createCrud<Series>({
|
||||
prefix: "Media/series/",
|
||||
parse: parseSeries,
|
||||
render: renderSeries,
|
||||
hasThumbnails: true,
|
||||
});
|
||||
|
||||
export const getSeries = crud.read;
|
||||
export const getAllSeries = crud.readAll;
|
||||
export const createSeries = crud.create;
|
||||
|
||||
@@ -30,3 +30,14 @@ export const resources = {
|
||||
prefix: "Media/series/",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function fetchResource(resource: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://marka.max-richter.dev/resources/${resource}`,
|
||||
);
|
||||
return response.json();
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { resources } from "@lib/resources.ts";
|
||||
import fuzzysort from "npm:fuzzysort";
|
||||
import { GenericResource } from "@lib/types.ts";
|
||||
import { extractHashTags } from "@lib/string.ts";
|
||||
import { getAllMovies, Movie } from "@lib/resource/movies.ts";
|
||||
import { Article, getAllArticles } from "@lib/resource/articles.ts";
|
||||
import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
|
||||
import { getAllSeries, Series } from "@lib/resource/series.ts";
|
||||
import { Movie } from "@lib/resource/movies.ts";
|
||||
import { Article } from "@lib/resource/articles.ts";
|
||||
import { Recipe } from "@lib/resource/recipes.ts";
|
||||
import { Series } from "@lib/resource/series.ts";
|
||||
import { fetchResource } from "./resources.ts";
|
||||
|
||||
type ResourceType = keyof typeof resources;
|
||||
|
||||
@@ -56,10 +57,10 @@ export async function searchResource(
|
||||
{ q, tags = [], types, rating }: SearchParams,
|
||||
): Promise<GenericResource[]> {
|
||||
const resources = (await Promise.all([
|
||||
(!types || types.includes("movie")) && getAllMovies(),
|
||||
(!types || types.includes("series")) && getAllSeries(),
|
||||
(!types || types.includes("article")) && getAllArticles(),
|
||||
(!types || types.includes("recipe")) && getAllRecipes(),
|
||||
(!types || types.includes("movie")) && fetchResource("movies"),
|
||||
(!types || types.includes("series")) && fetchResource("series"),
|
||||
(!types || types.includes("article")) && fetchResource("articles"),
|
||||
(!types || types.includes("recipe")) && fetchResource("recipes"),
|
||||
])).flat().filter(isResource);
|
||||
|
||||
const results: Record<string, GenericResource> = {};
|
||||
|
||||
Reference in New Issue
Block a user