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 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" />
|
||||||
|
@ -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);
|
||||||
}
|
}
|
@ -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…
Reference in New Issue
Block a user