diff --git a/src/app/api/search/query/route.ts b/src/app/api/search/query/route.ts index c433fec..18a0e0b 100644 --- a/src/app/api/search/query/route.ts +++ b/src/app/api/search/query/route.ts @@ -36,11 +36,11 @@ export async function POST(req: Request) { if (semantic && embeddingsEnabled) { // Compute query vector and run hybrid search const vector = await getEmbeddingForText(q); - return hybridSearch(q, vector, filters, { page, perPage, sort, alpha: 0.6 }); + return hybridSearch(q, vector, filters, { page, perPage, sort, alpha: 0.6, withFacets: true }); } // Fallback: pure BM25 - return bm25Search(q, filters, { page, perPage, sort }); + return bm25Search(q, filters, { page, perPage, sort, withFacets: true }); }, ); diff --git a/src/app/doc/[...path]/page.tsx b/src/app/doc/[...path]/page.tsx new file mode 100644 index 0000000..512e7f7 --- /dev/null +++ b/src/app/doc/[...path]/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import CodeMirror from "@uiw/react-codemirror"; +import { markdown } from "@codemirror/lang-markdown"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MarkdownEditor } from "@/components/editor/markdown-editor"; +import { toast } from "sonner"; +import { TagsDialog } from "@/components/files/tags-dialog"; +import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; + +type PageProps = { + params: { path?: string[] }; +}; + +async function fetchContent(path: string) { + const url = new URL("/api/files/content", window.location.origin); + url.searchParams.set("path", path); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load content (${res.status})`); + } + return (await res.json()) as { ok: boolean; content?: string; mimeType?: string }; +} + +async function fetchTagHistory(path: string) { + const url = new URL("/api/files/tags/history", window.location.origin); + url.searchParams.set("path", path); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load tag history (${res.status})`); + } + return (await res.json()) as { ok: boolean; history?: Array<{ id: string; path: string; actor?: string; action: string; from?: string[]; to: string[]; at: string }> }; +} + +function buildOutline(md?: string) { + if (!md) return [] as Array<{ level: number; text: string; line: number }>; + const lines = md.split(/\r?\n/); + const out: Array<{ level: number; text: string; line: number }> = []; + lines.forEach((ln, i) => { + const m = /^(#{1,6})\s+(.*)$/.exec(ln); + if (m) { + out.push({ level: m[1].length, text: m[2].trim(), line: i }); + } + }); + return out; +} + +function simpleUnifiedDiff(a: string, b: string) { + // naive line-by-line diff for small files + const aLines = a.split(/\r?\n/); + const bLines = b.split(/\r?\n/); + const max = Math.max(aLines.length, bLines.length); + const rows: Array<{ type: "ctx" | "del" | "add"; text: string }> = []; + for (let i = 0; i < max; i++) { + const A = aLines[i]; + const B = bLines[i]; + if (A === B) { + if (A !== undefined) rows.push({ type: "ctx", text: A }); + } else { + if (A !== undefined) rows.push({ type: "del", text: A }); + if (B !== undefined) rows.push({ type: "add", text: B }); + } + } + return rows; +} + +export default function DocPage({ params }: PageProps) { + const path = "/" + (params.path?.join("/") ?? ""); + const [activeTab, setActiveTab] = React.useState<"content" | "versions" | "diff" | "tags" | "history">("content"); + + const [editorOpen, setEditorOpen] = React.useState(false); + const [tagsOpen, setTagsOpen] = React.useState(false); + + // Content + const contentQuery = useQuery({ + queryKey: ["doc-content", path], + queryFn: () => fetchContent(path), + }); + + const outline = React.useMemo(() => buildOutline(contentQuery.data?.content || ""), [contentQuery.data?.content]); + + // Diff + const [comparePath, setComparePath] = React.useState(""); + const [diffRows, setDiffRows] = React.useState | null>(null); + const [diffError, setDiffError] = React.useState(null); + const [diffLoading, setDiffLoading] = React.useState(false); + + async function runDiff() { + try { + setDiffError(null); + setDiffLoading(true); + if (!contentQuery.data?.content) throw new Error("Primary document not loaded"); + const primary = contentQuery.data.content; + const primaryBytes = new TextEncoder().encode(primary).length; + const res = await fetchContent(comparePath); + const other = res.content ?? ""; + const otherBytes = new TextEncoder().encode(other).length; + if (primaryBytes > 500_000 || otherBytes > 500_000) { + throw new Error("Diff is limited to files ≤ 500KB each"); + } + const rows = simpleUnifiedDiff(other, primary); // show changes from other -> current + setDiffRows(rows); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setDiffError(msg); + toast.error(msg); + } finally { + setDiffLoading(false); + } + } + + // History + const historyQuery = useQuery({ + queryKey: ["doc-tag-history", path], + queryFn: () => fetchTagHistory(path), + enabled: activeTab === "history", + }); + + function downloadCurrent() { + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", path); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + + return ( +
+
+ {}} /> +
+ + + +
+
+ + {/* Tabs */} +
+ {(["content", "versions", "diff", "tags", "history"] as const).map((t) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === "content" ? ( +
+
+ +
+ +
+ ) : null} + + {activeTab === "versions" ? ( +
+ Versions are not yet integrated with Nextcloud for this MVP. Use Download to retrieve content or maintain your own revisions. This tab will be wired to Nextcloud versions when available. +
+ ) : null} + + {activeTab === "diff" ? ( +
+
+
+ + setComparePath(e.target.value)} + /> +
+ +
+ {diffError ?
{diffError}
: null} + {diffRows ? ( +
+                {diffRows.map((r, i) => {
+                  const prefix = r.type === "ctx" ? " " : r.type === "del" ? "-" : "+";
+                  const cls =
+                    r.type === "ctx"
+                      ? ""
+                      : r.type === "del"
+                        ? "text-red-500"
+                        : "text-green-500";
+                  return (
+                    
+ {prefix} {r.text} +
+ ); + })} +
+ ) : null} +
+ ) : null} + + {activeTab === "tags" ? ( +
+ + setTagsOpen(v)} + initialTags={[]} + onSaved={() => { + toast.success("Tags updated"); + }} + /> +
+ ) : null} + + {activeTab === "history" ? ( +
+
Tag History
+ {historyQuery.isLoading ?
Loading…
: null} + {historyQuery.data?.history && historyQuery.data.history.length > 0 ? ( +
+ {historyQuery.data.history.map((h) => ( +
+
{new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"}
+
Tags: {(h.to ?? []).join(", ")}
+
+ ))} +
+ ) : ( +
No tag history
+ )} +
+ ) : null} +
+ + {/* Editor dialog */} + setEditorOpen(v)}> + + + Edit Document + +
+ {editorOpen ? : null} +
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 28983d7..b808b04 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { useRouter } from "next/navigation"; import { SidebarTree } from "@/components/sidebar/sidebar-tree"; import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; import { FileTable, type FileRow } from "@/components/files/file-table"; @@ -65,6 +66,7 @@ async function executeSearch( } export default function Home() { + const router = useRouter(); const queryClient = useQueryClient(); const [path, setPath] = React.useState(undefined); const [page, setPage] = React.useState(1); @@ -119,9 +121,20 @@ export default function Home() { window.open(url.toString(), "_blank", "noopener,noreferrer"); } + function encodeDocPath(p: string) { + // Convert an absolute path like "/a/b c/d.md" -> "/doc/a/b%20c/d.md" + const segments = p.replace(/^\/+/, "").split("/"); + const encoded = segments.map((s) => encodeURIComponent(s)).join("/"); + return `/doc/${encoded}`; + } + + function navigateToDoc(p: string) { + router.push(encodeDocPath(p)); + } + function handleOpen(item: FileRow) { - // Placeholder for preview behavior; for now, download - handleDownload(item); + // Navigate to the Document View for the selected file + navigateToDoc(item.path); } function handleEdit(item: FileRow) { @@ -298,7 +311,7 @@ export default function Home() { window.open(url.toString(), "_blank", "noopener,noreferrer"); } function openByPath(p: string) { - downloadByPath(p); + navigateToDoc(p); } function tagsByPath(p: string) { setTagsPath(p); diff --git a/src/lib/elasticsearch.ts b/src/lib/elasticsearch.ts index a50e960..f4d7bf5 100644 --- a/src/lib/elasticsearch.ts +++ b/src/lib/elasticsearch.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { Client } from "@elastic/elasticsearch"; import type { estypes } from "@elastic/elasticsearch"; import { env, getEsAuth, embeddingsEnabled } from "@/lib/env"; -import type { FacetFilters, SearchResult, SortMode } from "@/types/search"; +import type { FacetFilters, SearchResult, SortMode, FacetResult } from "@/types/search"; import type { IndexDocument } from "@/types/ingest"; let _client: Client | null = null; @@ -96,6 +96,85 @@ function sortFromMode(sort: SortMode | undefined) { return undefined; } +// Build facet aggregations for ES query +function buildFacetAggs(): Record { + return { + types: { + terms: { + field: "mimeType", + size: 50, + }, + }, + owner: { + terms: { + field: "owner", + size: 50, + }, + }, + tags: { + terms: { + field: "tags", + size: 100, + }, + }, + }; +} + +type TermsAgg = { buckets?: Array<{ key: string | number; doc_count: number }> }; +type AggsShape = { types?: TermsAgg; owner?: TermsAgg; tags?: TermsAgg }; + +function toFacetResult(aggs?: AggsShape): FacetResult | undefined { + if (!aggs) return undefined; + const map = (buckets?: TermsAgg["buckets"]) => + buckets?.map((b) => ({ key: String(b.key), count: b.doc_count })) ?? []; + + return { + types: map(aggs.types?.buckets), + owner: map(aggs.owner?.buckets), + tags: map(aggs.tags?.buckets), + }; +} + +async function computeFacets( + q: string, + filters: FacetFilters = {}, +): Promise { + const client = getEsClient(); + const bool = filtersToBool(filters); + + const body: Record = { + size: 0, + query: { + bool: { + ...bool, + must: [ + ...(bool.must || []), + q + ? { + multi_match: { + query: q, + type: "best_fields", + fields: ["name^4", "nickname^3", "title^3", "tags^2", "content^1", "path.text^0.5"], + operator: "and", + fuzziness: "AUTO", + }, + } + : { match_all: {} }, + ], + }, + }, + aggs: buildFacetAggs(), + }; + + const res = await client.search({ + index: env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX, + body, + }); + + const aggs = res.aggregations as unknown as AggsShape | undefined; + return toFacetResult(aggs); +} + export async function ensureIndex(options?: { recreate?: boolean }) { const span = Sentry.startSpan( { op: "db.query", name: "ES ensureIndex" }, @@ -309,7 +388,7 @@ function toSearchResult( export async function bm25Search( q: string, filters: FacetFilters = {}, - opts: { page?: number; perPage?: number; sort?: SortMode } = {}, + opts: { page?: number; perPage?: number; sort?: SortMode; withFacets?: boolean } = {}, ): Promise { return Sentry.startSpan( { op: "db.query", name: "ES bm25Search" }, @@ -382,7 +461,12 @@ export async function bm25Search( highlight?: Record; }>, ); - return toSearchResult(hits, total, res.took ?? 0); + const base = toSearchResult(hits, total, res.took ?? 0); + if (opts.withFacets) { + const facets = await computeFacets(q, filters); + return { ...base, facets }; + } + return base; }, ); } @@ -448,7 +532,7 @@ export async function hybridSearch( q: string, vector: number[] | undefined, filters: FacetFilters = {}, - opts: { page?: number; perPage?: number; sort?: SortMode; alpha?: number } = {}, + opts: { page?: number; perPage?: number; sort?: SortMode; alpha?: number; withFacets?: boolean } = {}, ): Promise { const alpha = Math.max(0, Math.min(1, opts.alpha ?? 0.5)); const page = Math.max(1, opts.page ?? 1); @@ -519,11 +603,23 @@ export async function hybridSearch( const pageStart = (page - 1) * perPage; const pageItems = blended.slice(pageStart, pageStart + perPage); - return { + const result: SearchResult = { total: blended.length, tookMs: 0, - hits: pageItems, + hits: pageItems.map((h) => ({ + doc: h.doc as unknown as DocType, + score: h.score, + bm25Score: h.bm25Score, + vectorScore: h.vectorScore, + highlights: h.highlights, + })), }; + + if (opts.withFacets) { + const facets = await computeFacets(q, filters); + return { ...result, facets }; + } + return result; }, ); } diff --git a/src/types/search.ts b/src/types/search.ts index 1bb67f7..57c6f4e 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -20,6 +20,14 @@ export type Highlights = { content?: string[]; }; +export type FacetBucket = { key: string; count: number }; + +export type FacetResult = { + types?: FacetBucket[]; + owner?: FacetBucket[]; + tags?: FacetBucket[]; +}; + export interface SearchQuery { q: string; filters?: FacetFilters; @@ -41,4 +49,5 @@ export interface SearchResult { total: number; hits: SearchHit[]; tookMs: number; + facets?: FacetResult; }