feat: show documents with similar tags
This commit is contained in:
		
							parent
							
								
									fe310459da
								
							
						
					
					
						commit
						b068cf1def
					
				
					 10 changed files with 124 additions and 8 deletions
				
			
		|  | @ -63,7 +63,7 @@ export function NavList() { | ||||||
|     const loginInfo = useLogin(); |     const loginInfo = useLogin(); | ||||||
|     const navItems = useNavItems(); |     const navItems = useNavItems(); | ||||||
| 
 | 
 | ||||||
|     return <aside className="h-dvh flex flex-col"> |     return <aside className="h-dvh flex flex-col fixed"> | ||||||
|         <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1"> |         <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1"> | ||||||
|             {navItems && <>{navItems} <Separator/> </>} |             {navItems && <>{navItems} <Separator/> </>} | ||||||
|             <NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" /> |             <NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" /> | ||||||
|  |  | ||||||
|  | @ -15,3 +15,7 @@ export function useRehashDoc() { | ||||||
|         mutate(`/api/doc/${id}`); |         mutate(`/api/doc/${id}`); | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function useGalleryDocSimilar(id: string) { | ||||||
|  |     return useSWR<Document[]>(`/api/doc/${id}/similars`, fetcher); | ||||||
|  | } | ||||||
|  | @ -1,11 +1,12 @@ | ||||||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | ||||||
| import { useGalleryDoc, useRehashDoc } from "../hook/useGalleryDoc.ts"; | import { useGalleryDoc, useGalleryDocSimilar, useRehashDoc } from "../hook/useGalleryDoc.ts"; | ||||||
| import TagBadge from "@/components/gallery/TagBadge"; | import TagBadge from "@/components/gallery/TagBadge"; | ||||||
| import StyledLink from "@/components/gallery/StyledLink"; | import StyledLink from "@/components/gallery/StyledLink"; | ||||||
| import { Link } from "wouter"; | import { Link } from "wouter"; | ||||||
| import { classifyTags } from "../lib/classifyTags.tsx"; | import { classifyTags } from "../lib/classifyTags.tsx"; | ||||||
| import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx"; | import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx"; | ||||||
| import { Button } from "@/components/ui/button.tsx"; | import { Button } from "@/components/ui/button.tsx"; | ||||||
|  | import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; | ||||||
| 
 | 
 | ||||||
| export interface ContentInfoPageProps { | export interface ContentInfoPageProps { | ||||||
|     params: { |     params: { | ||||||
|  | @ -35,7 +36,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) { | ||||||
|     const contentLocation = `/doc/${params.id}/reader`; |     const contentLocation = `/doc/${params.id}/reader`; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <div className="p-4 h-dvh overflow-auto"> |         <div className="p-4"> | ||||||
|             <Link to={contentLocation}> |             <Link to={contentLocation}> | ||||||
|                 <div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733] |                 <div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733] | ||||||
|             rounded-xl shadow-lg overflow-hidden"> |             rounded-xl shadow-lg overflow-hidden"> | ||||||
|  | @ -83,8 +84,40 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) { | ||||||
|                     </div> |                     </div> | ||||||
|                 </CardContent> |                 </CardContent> | ||||||
|             </Card> |             </Card> | ||||||
|  |             <SimilarContentCard id={params.id} /> | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function SimilarContentCard({ | ||||||
|  |     id, | ||||||
|  | }: { | ||||||
|  |     id: string; | ||||||
|  | }){ | ||||||
|  |     const { data, error, isLoading } = useGalleryDocSimilar(id); | ||||||
|  | 
 | ||||||
|  |     if (isLoading) { | ||||||
|  |         return <div className="p-4">Loading...</div> | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (error) { | ||||||
|  |         return <div className="p-4">Error: {String(error)}</div> | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!data) { | ||||||
|  |         return <div className="p-4">Not found</div> | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div className="space-y-4 mt-4 mx-2"> | ||||||
|  |             <h2 className="text-2xl font-bold">Contents with Similar Tags</h2> | ||||||
|  |             <div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]"> | ||||||
|  |             {data.map((doc) => ( | ||||||
|  |                 <GalleryCard key={doc.id} doc={doc} /> | ||||||
|  |             ))} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default ContentInfoPage; | export default ContentInfoPage; | ||||||
|  | @ -65,7 +65,7 @@ export default function Gallery() { | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     const isLoadingMore = data && size > 0 && (data[size - 1] === undefined); |     const isLoadingMore = data && size > 0 && (data[size - 1] === undefined); | ||||||
|     return (<div className="p-4 grid gap-2 overflow-auto h-dvh items-start content-start" ref={parentRef}> |     return (<div className="p-4 grid gap-2 items-start content-start" ref={parentRef}> | ||||||
|         <Search /> |         <Search /> | ||||||
|         {(word || tags) && |         {(word || tags) && | ||||||
|             <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10"> |             <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10"> | ||||||
|  |  | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
|  | import { Kysely } from "kysely"; | ||||||
| import { getKysely } from "./db/kysely.ts"; | import { getKysely } from "./db/kysely.ts"; | ||||||
| 
 | 
 | ||||||
| export async function connectDB() { | export async function connectDB() { | ||||||
|  | @ -22,3 +23,22 @@ export async function connectDB() { | ||||||
| 	} | 	} | ||||||
| 	return kysely; | 	return kysely; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | async function checkTableExists(kysely: Kysely<any>, table: string) { | ||||||
|  | 	const result = await kysely.selectFrom("sqlite_master").where("type", "=", "table").where("name", "=", table).executeTakeFirst(); | ||||||
|  | 	return result !== undefined; | ||||||
|  | }  | ||||||
|  | 
 | ||||||
|  | export async function migrateDB() { | ||||||
|  | 	const kysely = getKysely(); | ||||||
|  | 	let version_number = 0; | ||||||
|  | 	// is schema_migration exists?
 | ||||||
|  | 	const hasTable = await checkTableExists(kysely, "schema_migration"); | ||||||
|  | 	if (!hasTable) { | ||||||
|  | 		// migrate from 0
 | ||||||
|  | 		// create schema_migration
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	const version = await kysely.selectFrom("schema_migration").executeTakeFirst(); | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -245,6 +245,41 @@ class SqliteDocumentAccessor implements DocumentAccessor { | ||||||
| 		c.tags.splice(c.tags.indexOf(tag_name), 1); | 		c.tags.splice(c.tags.indexOf(tag_name), 1); | ||||||
| 		return true; | 		return true; | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	async getSimilarDocument(doc: Document): Promise<Document[]> { | ||||||
|  | 		const tags = doc.tags.filter((x) => !x.startsWith("type:") | ||||||
|  | 			&& x !== "series:original"); | ||||||
|  | 		if (tags.length === 0) return []; | ||||||
|  | 		// get similar document based on tags count.
 | ||||||
|  | 		const results = await this.kysely | ||||||
|  | 			.selectFrom("document as doc") | ||||||
|  | 			.innerJoin("doc_tag_relation", "doc_tag_relation.doc_id", "doc.id") | ||||||
|  | 			.where("doc_tag_relation.tag_name", "in", tags) | ||||||
|  | 			.where("doc.id", "<>", doc.id) | ||||||
|  | 			.selectAll() | ||||||
|  | 			.select(qb => ([ | ||||||
|  | 				qb.fn.count("doc_tag_relation.tag_name").as("tag_count"), | ||||||
|  | 
 | ||||||
|  | 				qb.selectFrom(e => | ||||||
|  | 					e.selectFrom("doc_tag_relation") | ||||||
|  | 						.select(["doc_tag_relation.tag_name"]) | ||||||
|  | 						.whereRef("doc.id", "=", "doc_tag_relation.doc_id") | ||||||
|  | 						.as("agg") | ||||||
|  | 				).select(e => e.fn.agg<string>("json_group_array", ["agg.tag_name"]) | ||||||
|  | 					.as("tags_list") | ||||||
|  | 				).as("tags") | ||||||
|  | 			])) | ||||||
|  | 			.groupBy("doc.id") | ||||||
|  | 			.orderBy("tag_count", "desc") | ||||||
|  | 			.limit(10) | ||||||
|  | 			.execute(); | ||||||
|  | 		// TODO: tf-idf or other similarity calculation
 | ||||||
|  | 		return results.map((x) => ({ | ||||||
|  | 			...x, | ||||||
|  | 			tags: JSON.parse(x.tags ?? "[]"), | ||||||
|  | 			additional: {}, | ||||||
|  | 		})); | ||||||
|  | 	} | ||||||
| } | } | ||||||
| export const createSqliteDocumentAccessor = (kysely = getKysely()): DocumentAccessor => { | export const createSqliteDocumentAccessor = (kysely = getKysely()): DocumentAccessor => { | ||||||
| 	return new SqliteDocumentAccessor(kysely); | 	return new SqliteDocumentAccessor(kysely); | ||||||
|  |  | ||||||
|  | @ -85,4 +85,9 @@ export interface DocumentAccessor { | ||||||
| 	 * @returns if success, return true | 	 * @returns if success, return true | ||||||
| 	 */ | 	 */ | ||||||
| 	delTag: (c: Document, tag_name: string) => Promise<boolean>; | 	delTag: (c: Document, tag_name: string) => Promise<boolean>; | ||||||
|  | 	/** | ||||||
|  | 	 * get similar document | ||||||
|  | 	 * @returns similar document list | ||||||
|  | 	 */ | ||||||
|  | 	getSimilarDocument: (c: Document) => Promise<Document[]>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -16,6 +16,11 @@ const all_middleware = | ||||||
| 			ctx.status = 404; | 			ctx.status = 404; | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
|  | 		if (ctx.state.location === undefined) { | ||||||
|  | 			ctx.status = 404; | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		 | ||||||
| 		if (ctx.state.location.type !== cont) { | 		if (ctx.state.location.type !== cont) { | ||||||
| 			console.error("not matched"); | 			console.error("not matched"); | ||||||
| 			ctx.status = 404; | 			ctx.status = 404; | ||||||
|  |  | ||||||
|  | @ -184,18 +184,31 @@ function RehashContentHandler(controller: DocumentAccessor) { | ||||||
| 	}; | 	}; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function getSimilarDocumentHandler(controller: DocumentAccessor) { | ||||||
|  | 	return async (ctx: Context, next: Next) => { | ||||||
|  | 		const num = Number.parseInt(ctx.params.num); | ||||||
|  | 		const c = await controller.findById(num, true); | ||||||
|  | 		if (c === undefined) { | ||||||
|  | 			return sendError(404); | ||||||
|  | 		} | ||||||
|  | 		const r = await controller.getSimilarDocument(c); | ||||||
|  | 		ctx.body = r; | ||||||
|  | 		ctx.type = "json"; | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export const getContentRouter = (controller: DocumentAccessor) => { | export const getContentRouter = (controller: DocumentAccessor) => { | ||||||
| 	const ret = new Router(); | 	const ret = new Router(); | ||||||
| 	ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller)); | 	ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller)); | ||||||
| 	ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller)); | 	ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller)); | ||||||
|  | 	ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller)); | ||||||
| 	ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller)); | 	ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller)); | ||||||
| 	// ret.use("/:num(\\d+)/:content_type");
 | 	ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller)); | ||||||
| 	// ret.post("/",AdminOnly,CreateContentHandler(controller));
 | 	// ret.post("/",AdminOnly,CreateContentHandler(controller));
 | ||||||
|  | 	ret.get("/:num(\\d+)/similars", PerCheck(Per.QueryContent), getSimilarDocumentHandler(controller)); | ||||||
| 	ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller)); | 	ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller)); | ||||||
| 	ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller)); | 	ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller)); | ||||||
| 	ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller)); | 	ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller)); | ||||||
| 	ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller)); |  | ||||||
| 	ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller)); |  | ||||||
| 	ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes()); | 	ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes()); | ||||||
| 	ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller)); | 	ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller)); | ||||||
| 	return ret; | 	return ret; | ||||||
|  |  | ||||||
|  | @ -236,9 +236,10 @@ class ServerApplication { | ||||||
| 		return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0"); | 		return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0"); | ||||||
| 	} | 	} | ||||||
| 	static async createServer() { | 	static async createServer() { | ||||||
| 		const setting = get_setting(); |  | ||||||
| 		const db = await connectDB(); | 		const db = await connectDB(); | ||||||
| 
 | 
 | ||||||
|  | 		// todo : db migration
 | ||||||
|  | 
 | ||||||
| 		const app = new ServerApplication({ | 		const app = new ServerApplication({ | ||||||
| 			userController: createSqliteUserController(db), | 			userController: createSqliteUserController(db), | ||||||
| 			documentController: createSqliteDocumentAccessor(db), | 			documentController: createSqliteDocumentAccessor(db), | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue