feat: show documents with similar tags

This commit is contained in:
monoid 2024-10-18 02:12:33 +09:00
parent fe310459da
commit b068cf1def
10 changed files with 124 additions and 8 deletions

View File

@ -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" />

View File

@ -14,4 +14,8 @@ export function useRehashDoc() {
}); });
mutate(`/api/doc/${id}`); mutate(`/api/doc/${id}`);
}; };
}
export function useGalleryDocSimilar(id: string) {
return useSWR<Document[]>(`/api/doc/${id}/similars`, fetcher);
} }

View File

@ -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;

View File

@ -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">

View File

@ -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();
}

View File

@ -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);

View File

@ -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[]>;
} }

View File

@ -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;

View File

@ -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;

View File

@ -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),