feat: remove typesense
This commit is contained in:
		| @@ -65,7 +65,9 @@ const Image = ( | ||||
|         style={props.style} | ||||
|         srcset={responsiveAttributes.srcset} | ||||
|         sizes={responsiveAttributes.sizes} | ||||
|         src={`/api/images?image=${asset(props.src)}`} | ||||
|         src={`/api/images?image=${asset(props.src)}${ | ||||
|           props.width ? `&width=${props.width}` : "" | ||||
|         }${props.height ? `&height=${props.height}` : ""}`} | ||||
|         width={props.width} | ||||
|         height={props.height} | ||||
|         class={props.class} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { ComponentChildren } from "preact"; | ||||
| import Search from "@islands/Search.tsx"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource, SearchResult } from "@lib/types.ts"; | ||||
|  | ||||
| export type Props = { | ||||
|   children: ComponentChildren; | ||||
| @@ -9,7 +9,7 @@ export type Props = { | ||||
|   url: URL; | ||||
|   description?: string; | ||||
|   context?: { type: string }; | ||||
|   searchResults?: SearchResult; | ||||
|   searchResults?: GenericResource[]; | ||||
| }; | ||||
|  | ||||
| export const MainLayout = ( | ||||
|   | ||||
| @@ -33,6 +33,7 @@ | ||||
|     "@std/yaml": "jsr:@std/yaml@^1.0.5", | ||||
|     "drizzle-kit": "npm:drizzle-kit@^0.30.1", | ||||
|     "drizzle-orm": "npm:drizzle-orm@^0.38.3", | ||||
|     "fuzzysort": "npm:fuzzysort@^3.1.0", | ||||
|     "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/", | ||||
| @@ -42,7 +43,6 @@ | ||||
|     "tailwindcss/plugin": "npm:/tailwindcss@^3.4.17/plugin.js", | ||||
|     "camelcase-css": "npm:camelcase-css", | ||||
|     "tsx": "npm:tsx@^4.19.2", | ||||
|     "typesense": "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/mod.ts", | ||||
|     "yaml": "https://deno.land/std@0.197.0/yaml/mod.ts", | ||||
|     "zod": "https://deno.land/x/zod@v3.21.4/mod.ts", | ||||
|     "fs": "https://deno.land/std/fs/mod.ts", | ||||
|   | ||||
| @@ -8,16 +8,6 @@ services: | ||||
|     volumes: | ||||
|       - ./data/redis-data:/data | ||||
|  | ||||
|   typesense: | ||||
|     image: typesense/typesense:0.24.1 | ||||
|     restart: on-failure | ||||
|     ports: | ||||
|       - "8108:8108" | ||||
|     volumes: | ||||
|       - ./data/typesense-data:/data | ||||
|     env_file: .env | ||||
|     command: '--data-dir /data' | ||||
|  | ||||
| volumes: | ||||
|   redis-data: | ||||
|   typesense-data: | ||||
|   | ||||
| @@ -21,14 +21,12 @@ 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_query_sync from "./routes/api/query/sync.ts"; | ||||
| import * as $api_recipes_name_ from "./routes/api/recipes/[name].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_resources from "./routes/api/resources.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"; | ||||
| @@ -80,14 +78,12 @@ const manifest = { | ||||
|     "./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/query/sync.ts": $api_query_sync, | ||||
|     "./routes/api/recipes/[name].ts": $api_recipes_name_, | ||||
|     "./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/resources.ts": $api_resources, | ||||
|     "./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, | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Signal, useSignal } from "@preact/signals"; | ||||
| import { useEffect, useRef } from "preact/hooks"; | ||||
| import { useRef } from "preact/hooks"; | ||||
| import { useEventListener } from "@lib/hooks/useEventListener.ts"; | ||||
| import { menus } from "@islands/KMenu/commands.ts"; | ||||
| import { MenuEntry } from "@islands/KMenu/types.ts"; | ||||
|   | ||||
| @@ -31,13 +31,13 @@ export const menus: Record<string, Menu> = { | ||||
|         title: "Login", | ||||
|         icon: "IconLogin", | ||||
|         cb: () => { | ||||
|           const url = new URL(window.location.href); | ||||
|           const url = new URL(globalThis.location.href); | ||||
|           url.pathname = "/api/auth/login"; | ||||
|           url.searchParams.set( | ||||
|             "redirect", | ||||
|             encodeURIComponent(window.location.pathname), | ||||
|             encodeURIComponent(globalThis.location.pathname), | ||||
|           ); | ||||
|           window.location.href = url.href; | ||||
|           globalThis.location.href = url.href; | ||||
|         }, | ||||
|         visible: () => { | ||||
|           return !getCookie("session_cookie"); | ||||
| @@ -47,35 +47,24 @@ export const menus: Record<string, Menu> = { | ||||
|         title: "Search", | ||||
|         icon: "IconSearch", | ||||
|         cb: () => { | ||||
|           window.location.href += "?q="; | ||||
|           globalThis.location.href += "?q="; | ||||
|         }, | ||||
|         visible: () => { | ||||
|           return !!getCookie("session_cookie") && window.location.search === ""; | ||||
|           return !!getCookie("session_cookie") && | ||||
|             globalThis.location.search === ""; | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         title: "Logout", | ||||
|         icon: "IconLogout", | ||||
|         cb: () => { | ||||
|           const url = new URL(window.location.href); | ||||
|           const url = new URL(globalThis.location.href); | ||||
|           url.pathname = "/api/auth/logout"; | ||||
|           url.searchParams.set( | ||||
|             "redirect", | ||||
|             encodeURIComponent(window.location.pathname), | ||||
|             encodeURIComponent(globalThis.location.pathname), | ||||
|           ); | ||||
|           window.location.href = url.href; | ||||
|         }, | ||||
|         visible: () => { | ||||
|           return !!getCookie("session_cookie"); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         title: "Sync Typesense", | ||||
|         icon: "IconStarFilled", | ||||
|         cb: () => { | ||||
|           fetch("/api/query/sync", { | ||||
|             method: "POST", | ||||
|           }); | ||||
|           globalThis.location.href = url.href; | ||||
|         }, | ||||
|         visible: () => { | ||||
|           return !!getCookie("session_cookie"); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { useEffect, useRef } from "preact/hooks"; | ||||
| import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts"; | ||||
| import { IconLoader2, IconSearch } from "@components/icons.tsx"; | ||||
| import { useEventListener } from "@lib/hooks/useEventListener.ts"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource } from "@lib/types.ts"; | ||||
| import { resources } from "@lib/resources.ts"; | ||||
| import { getCookie } from "@lib/string.ts"; | ||||
| import { IS_BROWSER } from "$fresh/runtime.ts"; | ||||
| @@ -17,13 +17,13 @@ export async function fetchQueryResource(url: URL, type = "") { | ||||
|   const status = url.searchParams.get("status"); | ||||
|  | ||||
|   try { | ||||
|     url.pathname = "/api/resources"; | ||||
|     url.searchParams.set("q", encodeURIComponent(query || "*")); | ||||
|     url.pathname = "/api/query"; | ||||
|     url.searchParams.set("q", encodeURIComponent(query || "")); | ||||
|     if (status) { | ||||
|       url.searchParams.set("status", "not-seen"); | ||||
|     } | ||||
|     if (type) { | ||||
|       url.searchParams.set("type", type); | ||||
|       url.searchParams.set("types", type); | ||||
|     } | ||||
|     const response = await fetch(url); | ||||
|     const jsonData = await response.json(); | ||||
| @@ -39,9 +39,9 @@ export const RedirectSearchHandler = () => { | ||||
|       if (e?.target?.nodeName == "INPUT") return; | ||||
|       if ( | ||||
|         e.key === "?" && | ||||
|         window.location.search === "" | ||||
|         globalThis.location.search === "" | ||||
|       ) { | ||||
|         window.location.href += "?q=*"; | ||||
|         globalThis.location.href += "?q="; | ||||
|       } | ||||
|     }, IS_BROWSER ? document?.body : undefined); | ||||
|   } | ||||
| @@ -50,12 +50,12 @@ export const RedirectSearchHandler = () => { | ||||
| }; | ||||
|  | ||||
| const SearchResultImage = ({ src }: { src: string }) => { | ||||
|   const imageSrc = `/api/images?image=${src}&width=50&height=50`; | ||||
|  | ||||
|   return ( | ||||
|     <Image | ||||
|       class="object-cover w-12 h-12 rounded-full" | ||||
|       src={imageSrc} | ||||
|       width="50" | ||||
|       height="50" | ||||
|       src={src} | ||||
|       alt="preview image" | ||||
|     /> | ||||
|   ); | ||||
| @@ -63,13 +63,12 @@ const SearchResultImage = ({ src }: { src: string }) => { | ||||
|  | ||||
| export const SearchResultItem = ( | ||||
|   { item, showEmoji = false }: { | ||||
|     item: NonNullable<SearchResult["hits"]>[number]; | ||||
|     item: GenericResource; | ||||
|     showEmoji?: boolean; | ||||
|   }, | ||||
| ) => { | ||||
|   const doc = item.document; | ||||
|   const resourceType = resources[doc.type]; | ||||
|   const href = resourceType ? `${resourceType.link}/${doc.id}` : ""; | ||||
|   const resourceType = resources[item.type]; | ||||
|   const href = resourceType ? `${resourceType.link}/${item.id}` : ""; | ||||
|   return ( | ||||
|     <a | ||||
|       href={href} | ||||
| @@ -78,21 +77,21 @@ export const SearchResultItem = ( | ||||
|       {showEmoji && resourceType | ||||
|         ? <Emoji class="w-7 h-7" name={resourceType.emoji} /> | ||||
|         : ""} | ||||
|       {doc?.image && <SearchResultImage src={doc.image} />} | ||||
|       {doc?.name} | ||||
|       {item.meta?.image && <SearchResultImage src={item.meta?.image} />} | ||||
|       {item?.name} | ||||
|     </a> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const SearchResultList = ( | ||||
|   { result, showEmoji }: { result: SearchResult; showEmoji?: boolean }, | ||||
|   { result, showEmoji }: { result: GenericResource[]; showEmoji?: boolean }, | ||||
| ) => { | ||||
|   return ( | ||||
|     <div class="mt-4"> | ||||
|       {result?.hits | ||||
|       {result?.length | ||||
|         ? ( | ||||
|           <div class="flex flex-col gap-4"> | ||||
|             {result.hits.map((hit) => ( | ||||
|             {result.map((hit) => ( | ||||
|               <SearchResultItem item={hit} showEmoji={showEmoji} /> | ||||
|             ))} | ||||
|           </div> | ||||
| @@ -106,17 +105,17 @@ const Search = ( | ||||
|   { q = "*", type, results }: { | ||||
|     q: string; | ||||
|     type?: string; | ||||
|     results?: SearchResult; | ||||
|     results?: GenericResource[]; | ||||
|   }, | ||||
| ) => { | ||||
|   const searchQuery = useSignal(q); | ||||
|   const data = useSignal<SearchResult | undefined>(results); | ||||
|   const data = useSignal<GenericResource[] | undefined>(results); | ||||
|   const isLoading = useSignal(false); | ||||
|   const showSeenStatus = useSignal(false); | ||||
|   const inputRef = useRef<HTMLInputElement>(null); | ||||
|  | ||||
|   if ("history" in globalThis) { | ||||
|     const u = new URL(window.location.href); | ||||
|     const u = new URL(globalThis.location.href); | ||||
|     if (u.searchParams.get("q") !== searchQuery.value) { | ||||
|       u.searchParams.set("q", searchQuery.value); | ||||
|     } | ||||
| @@ -126,14 +125,14 @@ const Search = ( | ||||
|       u.searchParams.delete("rating"); | ||||
|     } | ||||
|  | ||||
|     window.history.replaceState({}, "", u); | ||||
|     globalThis.history.replaceState({}, "", u); | ||||
|   } | ||||
|  | ||||
|   const fetchData = async () => { | ||||
|     try { | ||||
|       isLoading.value = true; | ||||
|       const jsonData = await fetchQueryResource( | ||||
|         new URL(window?.location.href), | ||||
|         new URL(globalThis?.location.href), | ||||
|         type, | ||||
|       ); | ||||
|       data.value = jsonData; | ||||
| @@ -188,7 +187,7 @@ const Search = ( | ||||
|         <Checkbox label="seen" checked={showSeenStatus} /> | ||||
|         <Rating rating={4} /> | ||||
|       </header> | ||||
|       {data?.value?.hits?.length && !isLoading.value | ||||
|       {data.value?.length && !isLoading.value | ||||
|         ? <SearchResultList showEmoji={!type} result={data.value} /> | ||||
|         : isLoading.value | ||||
|         ? <div /> | ||||
|   | ||||
| @@ -82,7 +82,7 @@ export function createCrud<T extends GenericResource>( | ||||
|       return addThumbnailToResource(res); | ||||
|     } | ||||
|  | ||||
|     return res; | ||||
|     return { ...res, content }; | ||||
|   } | ||||
|   function create(id: string, content: string | ArrayBuffer | T) { | ||||
|     const path = pathFromId(id); | ||||
|   | ||||
| @@ -11,7 +11,6 @@ import remarkFrontmatter, { | ||||
| import { SILVERBULLET_SERVER } from "@lib/env.ts"; | ||||
| import { fixRenderedMarkdown } from "@lib/helpers.ts"; | ||||
| import { createLogger } from "@lib/log.ts"; | ||||
| import * as typesense from "@lib/typesense.ts"; | ||||
| import { db } from "@lib/sqlite/sqlite.ts"; | ||||
| import { documentTable } from "@lib/sqlite/schema.ts"; | ||||
| import { eq } from "drizzle-orm/sql"; | ||||
| @@ -59,8 +58,6 @@ export function createDocument( | ||||
|  | ||||
|   log.info("creating document", { name }); | ||||
|  | ||||
|   typesense.synchronize(); | ||||
|  | ||||
|   return fetch(SILVERBULLET_SERVER + "/" + name, { | ||||
|     body: content, | ||||
|     method: "PUT", | ||||
|   | ||||
| @@ -22,13 +22,9 @@ export const SESSION_DURATION = duration ? +duration : (60 * 60 * 24); | ||||
|  | ||||
| export const JWT_SECRET = Deno.env.get("JWT_SECRET"); | ||||
|  | ||||
| export const TYPESENSE_URL = Deno.env.get("TYPESENSE_URL") || | ||||
|   "http://localhost:8108"; | ||||
| export const TYPESENSE_API_KEY = Deno.env.get("TYPESENSE_API_KEY"); | ||||
|  | ||||
| export const DATA_DIR = Deno.env.has("DATA_DIR") | ||||
|   ? path.resolve(Deno.env.get("DATA_DIR")!) | ||||
|   : path.resolve(Deno.cwd(), "data"); | ||||
|  | ||||
| export const LOG_LEVEL: string = Deno.env.get("LOG_LEVEL") || | ||||
|   "debug"; | ||||
|   "warn"; | ||||
|   | ||||
| @@ -1,17 +1,20 @@ | ||||
| import { resources } from "@lib/resources.ts"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { getTypeSenseClient } from "@lib/typesense.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"; | ||||
|  | ||||
| type ResourceType = keyof typeof resources; | ||||
|  | ||||
| type SearchParams = { | ||||
|   q: string; | ||||
|   type?: ResourceType; | ||||
|   types?: string[]; | ||||
|   tags?: string[]; | ||||
|   rating?: string; | ||||
|   author?: string; | ||||
|   query_by?: string; | ||||
|   rating?: number; | ||||
|   authors?: string[]; | ||||
| }; | ||||
|  | ||||
| export function parseResourceUrl(_url: string | URL): SearchParams | undefined { | ||||
| @@ -31,56 +34,60 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined { | ||||
|  | ||||
|     return { | ||||
|       q: query, | ||||
|       type: url.searchParams.get("type") as ResourceType || undefined, | ||||
|       types: url.searchParams.get("type")?.split(",") as ResourceType[] || | ||||
|         undefined, | ||||
|       tags: hashTags, | ||||
|       rating: url.searchParams.get("rating") || undefined, | ||||
|       query_by: url.searchParams.get("query_by") || undefined, | ||||
|       rating: url.searchParams.has("rating") | ||||
|         ? parseInt(url.searchParams.get("rating")!) | ||||
|         : undefined, | ||||
|     }; | ||||
|   } catch (_err) { | ||||
|     return undefined; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const isResource = ( | ||||
|   item: Movie | Series | Article | Recipe | boolean, | ||||
| ): item is Movie | Series | Article | Recipe => { | ||||
|   return !!item; | ||||
| }; | ||||
|  | ||||
| export async function searchResource( | ||||
|   { q, query_by = "name,description,author,tags", tags = [], type, rating }: | ||||
|     SearchParams, | ||||
| ): Promise<SearchResult> { | ||||
|   const typesenseClient = await getTypeSenseClient(); | ||||
|   if (!typesenseClient) { | ||||
|     throw new Error("Query not available"); | ||||
|   } | ||||
|   { q, tags = [], types, authors, rating }: SearchParams, | ||||
| ): Promise<GenericResource[]> { | ||||
|   console.log("searchResource", { q, tags, types, authors, rating }); | ||||
|  | ||||
|   const filter_by: string[] = []; | ||||
|  | ||||
|   if (type) { | ||||
|     filter_by.push(`type:=${type}`); | ||||
|   } | ||||
|   let resources = (await Promise.all([ | ||||
|     (!types || types.includes("movie")) && getAllMovies(), | ||||
|     (!types || types.includes("series")) && getAllSeries(), | ||||
|     (!types || types.includes("article")) && getAllArticles(), | ||||
|     (!types || types.includes("recipe")) && getAllRecipes(), | ||||
|   ])).flat().filter(isResource); | ||||
|  | ||||
|   if (tags?.length) { | ||||
|     filter_by.push(`tags:[${tags.map((t) => `\`${t}\``).join(",")}]`); | ||||
|     for (const tag of tags) { | ||||
|       q = q.replaceAll(`#${tag}`, ""); | ||||
|     } | ||||
|     if (!q.trim().length) { | ||||
|       q = "*"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (typeof rating !== "undefined") { | ||||
|     if (rating === "null") { | ||||
|       filter_by.push(`rating: null`); | ||||
|     } else { | ||||
|       filter_by.push(`rating: ${rating}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return await typesenseClient.collections("resources") | ||||
|     .documents().search({ | ||||
|       q, | ||||
|       query_by, | ||||
|       facet_by: "rating,author,tags", | ||||
|       max_facet_values: 10, | ||||
|       filter_by: filter_by.join(" && "), | ||||
|       per_page: 50, | ||||
|     resources = resources.filter((r) => { | ||||
|       return tags?.every((t) => r.tags.includes(t)); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (authors?.length) { | ||||
|     resources = resources.filter((r) => { | ||||
|       return r?.meta?.author && authors.includes(r?.meta?.author); | ||||
|     }); | ||||
|   } | ||||
|   if (rating) { | ||||
|     resources = resources.filter((r) => { | ||||
|       return r?.meta?.rating && r.meta.rating >= rating; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (q.length && q !== "*") { | ||||
|     const results = fuzzysort.go(q, resources, { | ||||
|       keys: ["content", "name", "description"], | ||||
|       threshold: 0.3, | ||||
|     }); | ||||
|     resources = results.map((r) => r.obj); | ||||
|   } | ||||
|  | ||||
|   return resources; | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { SearchResponse } from "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/src/Typesense/Documents.ts"; | ||||
| import { resources } from "@lib/resources.ts"; | ||||
|  | ||||
| export interface TMDBMovie { | ||||
| @@ -39,6 +38,7 @@ export type GenericResource = { | ||||
|   id: string; | ||||
|   tags?: string[]; | ||||
|   type: keyof typeof resources; | ||||
|   content?: string; | ||||
|   meta?: { | ||||
|     image?: string; | ||||
|     author?: string; | ||||
| @@ -58,7 +58,7 @@ export interface GiteaOauthUser { | ||||
|   groups: any; | ||||
| } | ||||
|  | ||||
| export type TypesenseDocument = { | ||||
| export type SearchResult = { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   type: keyof typeof resources; | ||||
| @@ -68,5 +68,3 @@ export type TypesenseDocument = { | ||||
|   description?: string; | ||||
|   image?: string; | ||||
| }; | ||||
|  | ||||
| export type SearchResult = SearchResponse<TypesenseDocument>; | ||||
|   | ||||
							
								
								
									
										178
									
								
								lib/typesense.ts
									
									
									
									
									
								
							
							
						
						
									
										178
									
								
								lib/typesense.ts
									
									
									
									
									
								
							| @@ -1,178 +0,0 @@ | ||||
| import { Client } from "typesense"; | ||||
| import { TYPESENSE_API_KEY, TYPESENSE_URL } from "@lib/env.ts"; | ||||
| import { getAllMovies } from "@lib/resource/movies.ts"; | ||||
| import { getAllRecipes } from "@lib/resource/recipes.ts"; | ||||
| import { getAllArticles } from "@lib/resource/articles.ts"; | ||||
| import { createLogger } from "@lib/log.ts"; | ||||
| import { debounce } from "https://deno.land/std@0.193.0/async/mod.ts"; | ||||
| import { getAllSeries } from "@lib/resource/series.ts"; | ||||
| import { TypesenseDocument } from "@lib/types.ts"; | ||||
|  | ||||
| const log = createLogger("typesense"); | ||||
|  | ||||
| function sanitizeStringForTypesense(input: string) { | ||||
|   // Remove backslashes | ||||
|   const withoutBackslashes = input.replace(/\\/g, ""); | ||||
|  | ||||
|   // Remove control characters other than carriage return and line feed | ||||
|   const withoutControlCharacters = withoutBackslashes.replace( | ||||
|     /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, | ||||
|     "", | ||||
|   ); | ||||
|  | ||||
|   // Remove Unicode characters above U+FFFF | ||||
|   const withoutUnicodeAboveFFFF = withoutControlCharacters.replace( | ||||
|     /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, | ||||
|     "", | ||||
|   ); | ||||
|  | ||||
|   return withoutUnicodeAboveFFFF; | ||||
| } | ||||
|  | ||||
| // Use a promise to initialize the client as needed, rather than at import time. | ||||
| let clientPromise: Promise<Client | null> | undefined; | ||||
|  | ||||
| export function getTypeSenseClient(): Promise<Client | null> { | ||||
|   if (clientPromise === undefined) { | ||||
|     let typesenseUrl: URL; | ||||
|     try { | ||||
|       typesenseUrl = new URL(TYPESENSE_URL); | ||||
|     } catch (_err) { | ||||
|       return Promise.resolve(null); | ||||
|     } | ||||
|  | ||||
|     clientPromise = new Promise((resolve) => { | ||||
|       if (!TYPESENSE_API_KEY) { | ||||
|         return resolve(null); | ||||
|       } | ||||
|       const client = new Client({ | ||||
|         nodes: [{ | ||||
|           host: typesenseUrl.hostname, | ||||
|           port: +typesenseUrl.port || 8108, | ||||
|           protocol: typesenseUrl.protocol.slice(0, -1), | ||||
|         }], | ||||
|         apiKey: TYPESENSE_API_KEY, | ||||
|         connectionTimeoutSeconds: 2, | ||||
|       }); | ||||
|       resolve(client); | ||||
|     }); | ||||
|   } | ||||
|   return clientPromise; | ||||
| } | ||||
|  | ||||
| async function initializeTypesense() { | ||||
|   try { | ||||
|     const client = await getTypeSenseClient(); | ||||
|     if (!client) return; | ||||
|     // Create the "resources" collection if it doesn't exist | ||||
|     const collections = await client.collections().retrieve(); | ||||
|     const resourcesCollection = collections.find((collection) => | ||||
|       collection.name === "resources" | ||||
|     ); | ||||
|     if (!resourcesCollection) { | ||||
|       await client.collections().create({ | ||||
|         name: "resources", | ||||
|         fields: [ | ||||
|           { name: "name", type: "string" }, | ||||
|           { name: "type", type: "string", facet: true }, | ||||
|           { name: "date", type: "string", optional: true }, | ||||
|           { name: "author", type: "string", facet: true, optional: true }, | ||||
|           { name: "rating", type: "int32", facet: true }, | ||||
|           { name: "tags", type: "string[]", facet: true }, | ||||
|           { name: "description", type: "string", optional: true }, | ||||
|           { name: "image", type: "string", optional: true }, | ||||
|         ], | ||||
|         default_sorting_field: "rating", // Default field for sorting | ||||
|       }); | ||||
|       log.info('created "resources" collection'); | ||||
|     } else { | ||||
|       log.info('collection "resources" already exists.'); | ||||
|     } | ||||
|   } catch (error) { | ||||
|     log.error("error initializing", error); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const init = initializeTypesense(); | ||||
|  | ||||
| export async function createTypesenseDocument(doc: TypesenseDocument) { | ||||
|   const client = await getTypeSenseClient(); | ||||
|   if (!client) return; | ||||
|  | ||||
|   // await client.collections("resources").documents().create( | ||||
|   //   doc, | ||||
|   //   { action: "upsert" }, | ||||
|   // ); | ||||
| } | ||||
|  | ||||
| async function synchronizeWithTypesense() { | ||||
|   return; | ||||
|   await init; | ||||
|   try { | ||||
|     const allResources = (await Promise.all([ | ||||
|       getAllMovies(), | ||||
|       getAllArticles(), | ||||
|       getAllRecipes(), | ||||
|       getAllSeries(), | ||||
|     ])).flat(); // Replace with your function to get all resources from the database | ||||
|  | ||||
|     const client = await getTypeSenseClient(); | ||||
|     if (!client) return; | ||||
|  | ||||
|     // Convert the list of documents to Typesense compatible format (array of objects) | ||||
|     const typesenseDocuments = allResources.map((resource) => { | ||||
|       return { | ||||
|         id: resource.id, // Convert the document ID to a string, as Typesense only supports string IDs | ||||
|         name: sanitizeStringForTypesense(resource.name), | ||||
|         description: sanitizeStringForTypesense( | ||||
|           resource?.description || resource?.content || "", | ||||
|         ), | ||||
|         author: resource.meta?.author, | ||||
|         image: resource.meta?.image, | ||||
|         tags: resource?.tags || [], | ||||
|         rating: resource.meta?.rating || 0, | ||||
|         date: resource.meta?.date?.toString() || "", | ||||
|         type: resource.type, | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     return; | ||||
|  | ||||
|     await client.collections("resources").documents().import( | ||||
|       typesenseDocuments, | ||||
|       { action: "upsert" }, | ||||
|     ); | ||||
|  | ||||
|     // Get all the IDs of documents currently indexed in Typesense | ||||
|     const allTypesenseDocuments = await client.collections("resources") | ||||
|       .documents().search({ | ||||
|         q: "*", | ||||
|         query_by: "name,type,date,description", | ||||
|         per_page: 250, | ||||
|         limit_hits: 9999, | ||||
|       }); | ||||
|  | ||||
|     const deletedDocumentIds = allTypesenseDocuments.hits | ||||
|       ?.map((doc) => doc?.document?.id) | ||||
|       ?.filter((id) => | ||||
|         // Find deleted document IDs by comparing the Typesense document IDs with the current list of resources | ||||
|         !allResources.some((resource) => resource.id.toString() === id) | ||||
|       ).map((id) => client.collections("resources").documents(id).delete()); | ||||
|  | ||||
|     // Delete the documents with IDs found in deletedDocumentIds | ||||
|     if (deletedDocumentIds) { | ||||
|       await Promise.all( | ||||
|         deletedDocumentIds, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     log.info("data synchronized"); | ||||
|   } catch (error) { | ||||
|     log.error("error synchronizing", error); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Call the synchronizeWithTypesense function to trigger the synchronization | ||||
| synchronizeWithTypesense(); | ||||
|  | ||||
| export const synchronize = debounce(synchronizeWithTypesense, 1000 * 60 * 5); | ||||
| @@ -1,15 +1,7 @@ | ||||
| import { Handlers } from "$fresh/server.ts"; | ||||
| import { json } from "@lib/helpers.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 { AccessDeniedError } from "@lib/errors.ts"; | ||||
|  | ||||
| const isResource = ( | ||||
|   item: Movie | Article | Recipe | boolean, | ||||
| ): item is Movie | Article | Recipe => { | ||||
|   return !!item; | ||||
| }; | ||||
| import { searchResource } from "@lib/search.ts"; | ||||
|  | ||||
| export const handler: Handlers = { | ||||
|   async GET(req, ctx) { | ||||
| @@ -20,27 +12,16 @@ export const handler: Handlers = { | ||||
|  | ||||
|     const url = new URL(req.url); | ||||
|  | ||||
|     const types = url.searchParams.get("type")?.split(", "); | ||||
|  | ||||
|     let resources = (await Promise.all([ | ||||
|       (!types || types.includes("movie")) && getAllMovies(), | ||||
|       (!types || types.includes("article")) && getAllArticles(), | ||||
|       (!types || types.includes("recipe")) && getAllRecipes(), | ||||
|     ])).flat().filter(isResource); | ||||
|  | ||||
|     const types = url.searchParams.get("types")?.split(","); | ||||
|     const tags = url.searchParams?.get("tags")?.split(","); | ||||
|     if (tags?.length) { | ||||
|       resources = resources.filter((r) => { | ||||
|         return tags?.every((t) => r.tags.includes(t)); | ||||
|       }); | ||||
|     } | ||||
|     const authors = url.searchParams?.get("authors")?.split(","); | ||||
|  | ||||
|     const authors = url.searchParams?.get("author")?.split(","); | ||||
|     if (authors?.length) { | ||||
|       resources = resources.filter((r) => { | ||||
|         return r?.meta?.author && authors.includes(r?.meta?.author); | ||||
|       }); | ||||
|     } | ||||
|     const resources = await searchResource({ | ||||
|       q: url.searchParams.get("q") || "", | ||||
|       types, | ||||
|       tags, | ||||
|       authors, | ||||
|     }); | ||||
|  | ||||
|     return json(resources); | ||||
|   }, | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| import { AccessDeniedError } from "@lib/errors.ts"; | ||||
| import { Handlers } from "$fresh/server.ts"; | ||||
| import { synchronize } from "@lib/typesense.ts"; | ||||
|  | ||||
| export const handler: Handlers = { | ||||
|   POST(_, ctx) { | ||||
|     const session = ctx.state.session; | ||||
|     if (!session) { | ||||
|       throw new AccessDeniedError(); | ||||
|     } | ||||
|  | ||||
|     synchronize(); | ||||
|  | ||||
|     return new Response("OK"); | ||||
|   }, | ||||
| }; | ||||
| @@ -1,21 +0,0 @@ | ||||
| import { Handlers } from "$fresh/server.ts"; | ||||
| import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; | ||||
| import { getTypeSenseClient } from "@lib/typesense.ts"; | ||||
| import { json } from "@lib/helpers.ts"; | ||||
| import { parseResourceUrl, searchResource } from "@lib/search.ts"; | ||||
|  | ||||
| export const handler: Handlers = { | ||||
|   async GET(req, ctx) { | ||||
|     const session = ctx.state.session; | ||||
|     if (!session) { | ||||
|       throw new AccessDeniedError(); | ||||
|     } | ||||
|  | ||||
|     const searchParams = parseResourceUrl(req.url); | ||||
|  | ||||
|     // Perform the Typesense search | ||||
|     const searchResults = await searchResource(searchParams); | ||||
|  | ||||
|     return json(searchResults); | ||||
|   }, | ||||
| }; | ||||
| @@ -1,29 +1,30 @@ | ||||
| import { Handlers, PageProps } from "$fresh/server.ts"; | ||||
| import { MainLayout } from "@components/layouts/main.tsx"; | ||||
| import { Article, getAllArticles } from "@lib/resource/articles.ts"; | ||||
| import { Card } from "@components/Card.tsx"; | ||||
| 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 { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource } from "@lib/types.ts"; | ||||
| import { ResourceCard } from "@components/Card.tsx"; | ||||
|  | ||||
| export const handler: Handlers< | ||||
|   { articles: Article[] | null; searchResults?: SearchResult } | ||||
|   { articles: Article[] | null; searchResults?: GenericResource[] } | ||||
| > = { | ||||
|   async GET(req, ctx) { | ||||
|     const articles = await getAllArticles(); | ||||
|     const searchParams = parseResourceUrl(req.url); | ||||
|     const searchResults = searchParams && | ||||
|       await searchResource({ ...searchParams, type: "article" }); | ||||
|       await searchResource({ ...searchParams, types: ["article"] }); | ||||
|     return ctx.render({ articles, searchResults }); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default function Greet( | ||||
|   props: PageProps<{ articles: Article[] | null; searchResults: SearchResult }>, | ||||
|   props: PageProps< | ||||
|     { articles: Article[] | null; searchResults: GenericResource[] } | ||||
|   >, | ||||
| ) { | ||||
|   const { articles, searchResults } = props.data; | ||||
|   return ( | ||||
|   | ||||
| @@ -6,16 +6,18 @@ import { IconArrowLeft } from "@components/icons.tsx"; | ||||
| import { KMenu } from "@islands/KMenu.tsx"; | ||||
| import { RedirectSearchHandler } from "@islands/Search.tsx"; | ||||
| import { parseResourceUrl, searchResource } from "@lib/search.ts"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource } from "@lib/types.ts"; | ||||
| import { PageProps } from "$fresh/server.ts"; | ||||
|  | ||||
| export default async function Greet( | ||||
|   props: PageProps<{ movies: Movie[] | null; searchResults: SearchResult }>, | ||||
|   props: PageProps< | ||||
|     { movies: Movie[] | null; searchResults: GenericResource[] } | ||||
|   >, | ||||
| ) { | ||||
|   const allMovies = await getAllMovies(); | ||||
|   const searchParams = parseResourceUrl(props.url); | ||||
|   const searchResults = searchParams && | ||||
|     await searchResource({ ...searchParams, type: "movie" }); | ||||
|     await searchResource({ ...searchParams, types: ["movie"] }); | ||||
|   const movies = allMovies.sort((a, b) => | ||||
|     a?.meta?.rating > b?.meta?.rating ? -1 : 1 | ||||
|   ); | ||||
|   | ||||
| @@ -6,23 +6,25 @@ import { IconArrowLeft } from "@components/icons.tsx"; | ||||
| import { KMenu } from "@islands/KMenu.tsx"; | ||||
| import { RedirectSearchHandler } from "@islands/Search.tsx"; | ||||
| import { parseResourceUrl, searchResource } from "@lib/search.ts"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource } from "@lib/types.ts"; | ||||
| import { ResourceCard } from "@components/Card.tsx"; | ||||
|  | ||||
| export const handler: Handlers< | ||||
|   { recipes: Recipe[] | null; searchResults?: SearchResult } | ||||
|   { recipes: Recipe[] | null; searchResults?: GenericResource[] } | ||||
| > = { | ||||
|   async GET(req, ctx) { | ||||
|     const recipes = await getAllRecipes(); | ||||
|     const searchParams = parseResourceUrl(req.url); | ||||
|     const searchResults = searchParams && | ||||
|       await searchResource({ ...searchParams, type: "recipe" }); | ||||
|       await searchResource({ ...searchParams, types: ["recipe"] }); | ||||
|     return ctx.render({ recipes, searchResults }); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default function Greet( | ||||
|   props: PageProps<{ recipes: Recipe[] | null; searchResults: SearchResult }>, | ||||
|   props: PageProps< | ||||
|     { recipes: Recipe[] | null; searchResults: GenericResource[] } | ||||
|   >, | ||||
| ) { | ||||
|   const { recipes, searchResults } = props.data; | ||||
|   return ( | ||||
|   | ||||
| @@ -7,22 +7,24 @@ import { RedirectSearchHandler } from "@islands/Search.tsx"; | ||||
| import { KMenu } from "@islands/KMenu.tsx"; | ||||
| import { ResourceCard } from "@components/Card.tsx"; | ||||
| import { parseResourceUrl, searchResource } from "@lib/search.ts"; | ||||
| import { SearchResult } from "@lib/types.ts"; | ||||
| import { GenericResource } from "@lib/types.ts"; | ||||
|  | ||||
| export const handler: Handlers< | ||||
|   { series: Series[] | null; searchResults?: SearchResult } | ||||
|   { series: Series[] | null; searchResults?: GenericResource[] } | ||||
| > = { | ||||
|   async GET(req, ctx) { | ||||
|     const series = await getAllSeries(); | ||||
|     const searchParams = parseResourceUrl(req.url); | ||||
|     const searchResults = searchParams && | ||||
|       await searchResource({ ...searchParams, type: "series" }); | ||||
|       await searchResource({ ...searchParams, types: ["series"] }); | ||||
|     return ctx.render({ series, searchResults }); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default function Greet( | ||||
|   props: PageProps<{ series: Series[] | null; searchResults: SearchResult }>, | ||||
|   props: PageProps< | ||||
|     { series: Series[] | null; searchResults: GenericResource[] } | ||||
|   >, | ||||
| ) { | ||||
|   const { series, searchResults } = props.data; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user