diff --git a/frontend/src/components/SectionDialog.vue b/frontend/src/components/SectionDialog.vue new file mode 100644 index 0000000..4bbb6ec --- /dev/null +++ b/frontend/src/components/SectionDialog.vue @@ -0,0 +1,233 @@ + + + diff --git a/frontend/src/data/navigation.js b/frontend/src/data/navigation.js index 87b6ad9..d41a65d 100644 --- a/frontend/src/data/navigation.js +++ b/frontend/src/data/navigation.js @@ -1,4 +1,10 @@ export const DESTINATIONS = [ + { + label: "Dashboard", + icon: "lucide-layout-dashboard", + route: "Dashboard", + activeOn: ["Dashboard"], + }, { label: "Projects", icon: "lucide-folder", diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..20afc5e --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,313 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 076bc6c..e5bd681 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -8,6 +8,11 @@ import { } from "@/data/settingsRoute"; const routes = [ + { + path: "/dashboard", + name: "Dashboard", + component: () => import("@/pages/Dashboard.vue"), + }, { path: "/", name: "Projects", diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..130f1f7 --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,8 @@ +export function pageRange(s) { + if (s.page_start == null) return ""; + return s.page_start === s.page_end ? `p${s.page_start}` : `p${s.page_start}–${s.page_end}`; +} + +export function plural(n, word, pluralWord = `${word}s`) { + return `${n} ${n === 1 ? word : pluralWord}`; +} diff --git a/wikify/api/dashboard.py b/wikify/api/dashboard.py new file mode 100644 index 0000000..aed4849 --- /dev/null +++ b/wikify/api/dashboard.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from collections import Counter, defaultdict + +import frappe + +from wikify.api.explore import UNTAGGED, UNTAGGED_COLOR, UNTAGGED_LABEL +from wikify.api.permission import assert_readable, readable_projects + + +def _subcategory(section: dict, by_name: dict[str, dict]) -> str: + while (parent := by_name.get(section["parent_source_section"])) is not None: + section = parent + return (section["title"] or "").strip() or "Untitled" + + +@frappe.whitelist() +def summary( + project: str | None = None, section_type: str | None = None, subcategory: str | None = None +) -> dict: + assert_readable(project) + projects = frappe.get_all( + "Wikify Project", + filters={"name": ["in", readable_projects()]}, + fields=["name", "project_name"], + order_by="is_default desc, project_name asc", + ) + docs = frappe.get_all( + "Source Document", + filters={"project": ["in", [p["name"] for p in projects]]}, + fields=["name", "title", "project"], + ) + doc_by_name = {d["name"]: d for d in docs} + sections = ( + frappe.get_all( + "Source Section", + filters={"source_document": ["in", list(doc_by_name)]}, + fields=[ + "name", + "title", + "hierarchy_path", + "section_type", + "source_document", + "parent_source_section", + "page_start", + "page_end", + ], + order_by="source_document asc, lft asc", + ) + if docs + else [] + ) + by_name = {s["name"]: s for s in sections} + for s in sections: + s["project"] = doc_by_name[s["source_document"]]["project"] + s["section_type"] = s["section_type"] or UNTAGGED + s["subcategory"] = _subcategory(s, by_name) + docs_with_sections = {s["source_document"] for s in sections} + parsed_docs = [d for d in docs if d["name"] in docs_with_sections] + + in_project = [s for s in sections if not project or s["project"] == project] + in_type = [s for s in in_project if not section_type or s["section_type"] == section_type] + in_sub = [s for s in in_type if not subcategory or s["subcategory"] == subcategory] + + docs_per_project = Counter(d["project"] for d in parsed_docs) + return { + "cards": { + "projects": len(projects), + "documents": len(parsed_docs), + "categories": len({s["section_type"] for s in sections}), + "sections": len(sections), + }, + "projects": [{**p, "documents": docs_per_project[p["name"]]} for p in projects], + "categories": _category_rows(in_project), + "subcategories": _subcategory_rows(in_type) if section_type else [], + "sections": _section_rows(in_sub, doc_by_name) if subcategory else [], + } + + +def _group(sections: list[dict], key: str) -> dict[str, list[dict]]: + groups: dict[str, list[dict]] = defaultdict(list) + for s in sections: + groups[s[key]].append(s) + return groups + + +def _category_rows(sections: list[dict]) -> list[dict]: + by_type = _group(sections, "section_type") + types = frappe.get_all( + "Section Type", + fields=["type_name", "label", "color"], + order_by="is_other asc, creation asc", + ) + types.append({"type_name": UNTAGGED, "label": UNTAGGED_LABEL, "color": UNTAGGED_COLOR}) + rows = [ + { + "type_name": t["type_name"], + "label": t["label"] or t["type_name"], + "color": t["color"] or "#9ca3af", + "sections": len(own), + "documents": len({s["source_document"] for s in own}), + "subcategories": len({s["subcategory"] for s in own}), + } + for t in types + if (own := by_type.get(t["type_name"])) + ] + return sorted(rows, key=lambda r: -r["sections"]) + + +def _subcategory_rows(sections: list[dict]) -> list[dict]: + rows = [ + { + "title": title, + "sections": len(own), + "documents": len({s["source_document"] for s in own}), + } + for title, own in _group(sections, "subcategory").items() + ] + return sorted(rows, key=lambda r: (-r["sections"], r["title"].lower())) + + +def _section_rows(sections: list[dict], doc_by_name: dict[str, dict]) -> list[dict]: + return [ + { + "name": s["name"], + "title": s["title"], + "hierarchy_path": s["hierarchy_path"], + "page_start": s["page_start"], + "page_end": s["page_end"], + "doc_title": doc_by_name[s["source_document"]]["title"] or s["source_document"], + } + for s in sections + ] + + +@frappe.whitelist() +def section_detail(name: str) -> dict: + section = frappe.db.get_value( + "Source Section", + name, + [ + "name", + "title", + "hierarchy_path", + "page_start", + "page_end", + "markdown", + "section_type", + "source_document", + "lft", + "rgt", + ], + as_dict=True, + ) + if not section: + raise frappe.DoesNotExistError + doc = frappe.db.get_value( + "Source Document", section.source_document, ["title", "project", "pdf", "page_count"], as_dict=True + ) + assert_readable(doc.project) + section_type = ( + frappe.get_cached_value("Section Type", section.section_type, ["label", "color"], as_dict=True) + if section.section_type + else {"label": UNTAGGED_LABEL, "color": UNTAGGED_COLOR} + ) or {} + ancestors = frappe.get_all( + "Source Section", + filters={ + "source_document": section.source_document, + "lft": ["<", section.lft], + "rgt": [">", section.rgt], + }, + pluck="title", + order_by="lft asc", + ) + children = frappe.get_all( + "Source Section", + filters={"parent_source_section": name}, + fields=["name", "title", "page_start", "page_end"], + order_by="lft asc", + ) + return { + "name": section.name, + "title": section.title, + "hierarchy_path": section.hierarchy_path, + "page_start": section.page_start, + "page_end": section.page_end, + "ancestors": ancestors, + "markdown": section.markdown, + "doc_title": doc.title, + "pdf": doc.pdf, + "page_count": doc.page_count, + "project_name": frappe.get_cached_value("Wikify Project", doc.project, "project_name"), + "category": section_type.get("label") or section.section_type, + "category_color": section_type.get("color"), + "children": children, + } diff --git a/wikify/api/explore.py b/wikify/api/explore.py index 1c2286c..a9e06f4 100644 --- a/wikify/api/explore.py +++ b/wikify/api/explore.py @@ -6,6 +6,8 @@ from wikify.api.permission import assert_readable, hidden_documents UNTAGGED = "__untagged__" +UNTAGGED_LABEL = "Untagged" +UNTAGGED_COLOR = "#cbd5e1" def _scope(source_document: str | None) -> dict: @@ -69,8 +71,8 @@ def type_summary(source_document: str | None = None, project: str | None = None) summary.append( { "type_name": UNTAGGED, - "label": "Untagged", - "color": "#cbd5e1", + "label": UNTAGGED_LABEL, + "color": UNTAGGED_COLOR, "is_other": 0, "count": counts[UNTAGGED], } diff --git a/wikify/tests/test_dashboard.py b/wikify/tests/test_dashboard.py new file mode 100644 index 0000000..4e97152 --- /dev/null +++ b/wikify/tests/test_dashboard.py @@ -0,0 +1,134 @@ +# Copyright (c) 2026, BWH and contributors +# For license information, please see license.txt +import frappe +from frappe.tests.utils import FrappeTestCase + +from wikify.api import dashboard +from wikify.api import projects as projects_api +from wikify.engine import store +from wikify.engine.loader.sectionizer import Section +from wikify.tests.test_ask_history import make_user + +OTHER_USER = "dashboard-other@example.com" + + +def _sec(title, level, path, p_start, p_end): + return Section( + title=title, + level=level, + hierarchy_path=path, + page_start=p_start, + page_end=p_end, + markdown=f"body of {title}", + ) + + +class TestDashboard(FrappeTestCase): + def setUp(self): + self.tag = frappe.generate_hash(length=6) + self.project = projects_api.create_project(f"Dashboard {self.tag}") + self.sd = frappe.get_doc( + { + "doctype": "Source Document", + "title": f"Policy {self.tag}", + "project": self.project, + "pdf": "/files/policy.pdf", + } + ).insert(ignore_permissions=True) + store.replace_sections( + self.sd.name, + [ + _sec("Medicines", 1, ["Medicines"], 1, 4), + _sec("Storage", 2, ["Medicines", "Storage"], 1, 2), + _sec("Disposal", 2, ["Medicines", "Disposal"], 3, 4), + _sec("Emergencies", 1, ["Emergencies"], 5, 6), + ], + ) + types = { + "Medicines": "medication_management", + "Storage": "medication_management", + "Disposal": "medication_management", + "Emergencies": "emergency_procedures", + } + self.names = {} + for row in frappe.get_all( + "Source Section", filters={"source_document": self.sd.name}, fields=["name", "title"] + ): + frappe.db.set_value("Source Section", row.name, "section_type", types[row.title]) + self.names[row.title] = row.name + self.addCleanup(frappe.set_user, "Administrator") + + def test_categories_are_scoped_to_the_project(self): + rows = {c["type_name"]: c for c in dashboard.summary(project=self.project)["categories"]} + self.assertEqual(set(rows), {"medication_management", "emergency_procedures"}) + self.assertEqual(rows["medication_management"]["sections"], 3) + self.assertEqual(rows["medication_management"]["subcategories"], 1) + self.assertEqual(rows["medication_management"]["documents"], 1) + + def test_subcategory_is_the_top_level_heading(self): + result = dashboard.summary(project=self.project, section_type="medication_management") + self.assertEqual(result["subcategories"], [{"title": "Medicines", "sections": 3, "documents": 1}]) + self.assertEqual(result["sections"], []) + + def test_sections_listed_for_a_subcategory(self): + result = dashboard.summary( + project=self.project, section_type="medication_management", subcategory="Medicines" + ) + self.assertEqual({s["title"] for s in result["sections"]}, {"Medicines", "Storage", "Disposal"}) + self.assertEqual({s["doc_title"] for s in result["sections"]}, {f"Policy {self.tag}"}) + + def test_cards_ignore_the_drill_filters(self): + everything = dashboard.summary()["cards"] + drilled = dashboard.summary(project=self.project, section_type="emergency_procedures")["cards"] + self.assertEqual(everything, drilled) + + def test_section_detail_returns_content_children_and_pdf(self): + detail = dashboard.section_detail(self.names["Medicines"]) + self.assertEqual(detail["markdown"], "body of Medicines") + self.assertEqual(detail["pdf"], "/files/policy.pdf") + self.assertEqual([c["title"] for c in detail["children"]], ["Storage", "Disposal"]) + self.assertEqual(detail["category"], "Medication Management") + + def test_unreadable_project_is_hidden(self): + frappe.set_user(make_user(OTHER_USER)) + self.assertNotIn(self.project, [p["name"] for p in dashboard.summary()["projects"]]) + with self.assertRaises(frappe.PermissionError): + dashboard.summary(project=self.project) + with self.assertRaises(frappe.PermissionError): + dashboard.section_detail(self.names["Storage"]) + + def test_heading_containing_the_path_separator_stays_whole(self): + store.replace_sections( + self.sd.name, + [ + _sec("Temperature > 25C", 1, ["Temperature > 25C"], 1, 2), + _sec("Fridge", 2, ["Temperature > 25C", "Fridge"], 2, 2), + ], + ) + frappe.db.set_value( + "Source Section", {"source_document": self.sd.name}, "section_type", "medication_management" + ) + result = dashboard.summary(project=self.project, section_type="medication_management") + self.assertEqual([s["title"] for s in result["subcategories"]], ["Temperature > 25C"]) + fridge = frappe.db.get_value("Source Section", {"source_document": self.sd.name, "title": "Fridge"}) + self.assertEqual(dashboard.section_detail(fridge)["ancestors"], ["Temperature > 25C"]) + + def test_section_detail_returns_ancestors_and_page_count(self): + frappe.db.set_value("Source Document", self.sd.name, "page_count", 6) + detail = dashboard.section_detail(self.names["Storage"]) + self.assertEqual(detail["ancestors"], ["Medicines"]) + self.assertEqual(detail["page_count"], 6) + self.assertEqual(dashboard.section_detail(self.names["Medicines"])["ancestors"], []) + + def test_documents_without_sections_are_not_counted(self): + frappe.get_doc( + {"doctype": "Source Document", "title": f"Failed {self.tag}", "project": self.project} + ).insert(ignore_permissions=True) + row = next(p for p in dashboard.summary()["projects"] if p["name"] == self.project) + self.assertEqual(row["documents"], 1) + + def test_untagged_section_detail_has_a_category(self): + frappe.db.set_value("Source Section", self.names["Disposal"], "section_type", None) + detail = dashboard.section_detail(self.names["Disposal"]) + self.assertEqual(detail["category"], "Untagged") + self.assertEqual(detail["category_color"], "#cbd5e1")