feat: show documents with similar tags
This commit is contained in:
parent
fe310459da
commit
b068cf1def
@ -63,7 +63,7 @@ export function NavList() {
|
||||
const loginInfo = useLogin();
|
||||
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">
|
||||
{navItems && <>{navItems} <Separator/> </>}
|
||||
<NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
|
||||
|
@ -15,3 +15,7 @@ export function useRehashDoc() {
|
||||
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 { useGalleryDoc, useRehashDoc } from "../hook/useGalleryDoc.ts";
|
||||
import { useGalleryDoc, useGalleryDocSimilar, useRehashDoc } from "../hook/useGalleryDoc.ts";
|
||||
import TagBadge from "@/components/gallery/TagBadge";
|
||||
import StyledLink from "@/components/gallery/StyledLink";
|
||||
import { Link } from "wouter";
|
||||
import { classifyTags } from "../lib/classifyTags.tsx";
|
||||
import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
|
||||
|
||||
export interface ContentInfoPageProps {
|
||||
params: {
|
||||
@ -35,7 +36,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
|
||||
const contentLocation = `/doc/${params.id}/reader`;
|
||||
|
||||
return (
|
||||
<div className="p-4 h-dvh overflow-auto">
|
||||
<div className="p-4">
|
||||
<Link to={contentLocation}>
|
||||
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
|
||||
rounded-xl shadow-lg overflow-hidden">
|
||||
@ -83,8 +84,40 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SimilarContentCard id={params.id} />
|
||||
</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;
|
@ -65,7 +65,7 @@ export default function Gallery() {
|
||||
|
||||
|
||||
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 />
|
||||
{(word || tags) &&
|
||||
<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";
|
||||
|
||||
export async function connectDB() {
|
||||
@ -22,3 +23,22 @@ export async function connectDB() {
|
||||
}
|
||||
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);
|
||||
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 => {
|
||||
return new SqliteDocumentAccessor(kysely);
|
||||
|
@ -85,4 +85,9 @@ export interface DocumentAccessor {
|
||||
* @returns if success, return true
|
||||
*/
|
||||
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;
|
||||
return;
|
||||
}
|
||||
if (ctx.state.location === undefined) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.location.type !== cont) {
|
||||
console.error("not matched");
|
||||
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) => {
|
||||
const ret = new Router();
|
||||
ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(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.use("/:num(\\d+)/:content_type");
|
||||
ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(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.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+)", AdminOnly, DeleteContentHandler(controller));
|
||||
ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller));
|
||||
ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes());
|
||||
ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller));
|
||||
return ret;
|
||||
|
@ -236,9 +236,10 @@ class ServerApplication {
|
||||
return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0");
|
||||
}
|
||||
static async createServer() {
|
||||
const setting = get_setting();
|
||||
const db = await connectDB();
|
||||
|
||||
// todo : db migration
|
||||
|
||||
const app = new ServerApplication({
|
||||
userController: createSqliteUserController(db),
|
||||
documentController: createSqliteDocumentAccessor(db),
|
||||
|
Loading…
Reference in New Issue
Block a user