pinakes-ui: add graph view, backlinks panel, and link extraction

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibf40b009f5d18d16fc115b349b1f681d6a6a6964
This commit is contained in:
raf 2026-02-09 13:14:57 +03:00
commit 3e1e8dea26
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 103 additions and 43 deletions

View file

@ -17,8 +17,9 @@ reqwest = { workspace = true }
dioxus = { workspace = true } dioxus = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
rfd = "0.17" rfd = { workspace = true}
pulldown-cmark = { workspace = true } pulldown-cmark = { workspace = true }
gray_matter = { workspace = true } gray_matter = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
ammonia = "4" ammonia = { workspace = true}
dioxus-free-icons = {workspace = true}

View file

@ -1168,7 +1168,11 @@ impl ApiClient {
} }
/// Get graph data for visualization. /// Get graph data for visualization.
pub async fn get_graph(&self, center_id: Option<&str>, depth: Option<u32>) -> Result<GraphResponse> { pub async fn get_graph(
&self,
center_id: Option<&str>,
depth: Option<u32>,
) -> Result<GraphResponse> {
let mut url = self.url("/notes/graph"); let mut url = self.url("/notes/graph");
let mut query_parts = Vec::new(); let mut query_parts = Vec::new();
if let Some(center) = center_id { if let Some(center) = center_id {

View file

@ -94,7 +94,11 @@ pub fn BacklinksPanel(
collapsed.set(!current); collapsed.set(!current);
}, },
span { class: "backlinks-toggle", span { class: "backlinks-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" } if is_collapsed {
"\u{25b6}"
} else {
"\u{25bc}"
}
} }
span { class: "backlinks-title", "Backlinks" } span { class: "backlinks-title", "Backlinks" }
span { class: "backlinks-count", "({count})" } span { class: "backlinks-count", "({count})" }
@ -116,8 +120,7 @@ pub fn BacklinksPanel(
div { class: "backlinks-content", div { class: "backlinks-content",
// Show reindex message if present // Show reindex message if present
if let Some((ref msg, is_err)) = *reindex_message.read() { if let Some((ref msg, is_err)) = *reindex_message.read() {
div { div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" },
class: if is_err { "backlinks-message error" } else { "backlinks-message success" },
"{msg}" "{msg}"
} }
} }
@ -136,9 +139,7 @@ pub fn BacklinksPanel(
if !is_loading && error.read().is_none() { if !is_loading && error.read().is_none() {
if let Some(ref data) = *backlink_data { if let Some(ref data) = *backlink_data {
if data.backlinks.is_empty() { if data.backlinks.is_empty() {
div { class: "backlinks-empty", div { class: "backlinks-empty", "No other notes link to this one." }
"No other notes link to this one."
}
} else { } else {
ul { class: "backlinks-list", ul { class: "backlinks-list",
for backlink in &data.backlinks { for backlink in &data.backlinks {
@ -159,10 +160,7 @@ pub fn BacklinksPanel(
/// Individual backlink item view. /// Individual backlink item view.
#[component] #[component]
fn BacklinkItemView( fn BacklinkItemView(backlink: BacklinkItem, on_navigate: EventHandler<String>) -> Element {
backlink: BacklinkItem,
on_navigate: EventHandler<String>,
) -> Element {
let source_id = backlink.source_id.clone(); let source_id = backlink.source_id.clone();
let title = backlink let title = backlink
.source_title .source_title
@ -250,7 +248,11 @@ pub fn OutgoingLinksPanel(
collapsed.set(!current); collapsed.set(!current);
}, },
span { class: "outgoing-links-toggle", span { class: "outgoing-links-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" } if is_collapsed {
"\u{25b6}"
} else {
"\u{25bc}"
}
} }
span { class: "outgoing-links-title", "Outgoing Links" } span { class: "outgoing-links-title", "Outgoing Links" }
span { class: "outgoing-links-count", "({count})" } span { class: "outgoing-links-count", "({count})" }
@ -279,9 +281,7 @@ pub fn OutgoingLinksPanel(
if !is_loading && error.read().is_none() { if !is_loading && error.read().is_none() {
if let Some(ref data) = *link_data { if let Some(ref data) = *link_data {
if data.links.is_empty() { if data.links.is_empty() {
div { class: "outgoing-links-empty", div { class: "outgoing-links-empty", "This note has no outgoing links." }
"This note has no outgoing links."
}
} else { } else {
ul { class: "outgoing-links-list", ul { class: "outgoing-links-list",
for link in &data.links { for link in &data.links {
@ -323,7 +323,11 @@ fn OutgoingLinkItemView(
let link_type = link.link_type.clone(); let link_type = link.link_type.clone();
let display_text = link_text.unwrap_or_else(|| target_path.clone()); let display_text = link_text.unwrap_or_else(|| target_path.clone());
let resolved_class = if is_resolved { "resolved" } else { "unresolved" }; let resolved_class = if is_resolved {
"resolved"
} else {
"unresolved"
};
rsx! { rsx! {
li { li {

View file

@ -68,9 +68,7 @@ pub fn GraphView(
} }
} }
if let Some(ref data) = *data { if let Some(ref data) = *data {
div { class: "graph-stats", div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" }
"{data.node_count} nodes, {data.edge_count} edges"
}
} }
} }
@ -176,8 +174,9 @@ fn GraphSvg(
for edge in &edges { for edge in &edges {
if let (Some(&(x1, y1)), Some(&(x2, y2))) = ( if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
id_to_pos.get(edge.source.as_str()), id_to_pos.get(edge.source.as_str()),
id_to_pos.get(edge.target.as_str()) id_to_pos.get(edge.target.as_str()),
) { )
{
line { line {
class: "graph-edge edge-type-{edge.link_type}", class: "graph-edge edge-type-{edge.link_type}",
x1: "{x1}", x1: "{x1}",
@ -201,10 +200,7 @@ fn GraphSvg(
ref_x: "10", ref_x: "10",
ref_y: "3.5", ref_y: "3.5",
orient: "auto", orient: "auto",
polygon { polygon { points: "0 0, 10 3.5, 0 7", fill: "#888" }
points: "0 0, 10 3.5, 0 7",
fill: "#888",
}
} }
} }
@ -226,6 +222,7 @@ fn GraphSvg(
onclick: move |_| on_node_click.call(node_id.clone()), onclick: move |_| on_node_click.call(node_id.clone()),
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()), ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()),
circle { circle {
cx: "{x}", cx: "{x}",
cy: "{y}", cy: "{y}",
@ -264,11 +261,7 @@ fn NodeDetailsPanel(
div { class: "node-details-panel", div { class: "node-details-panel",
div { class: "node-details-header", div { class: "node-details-header",
h3 { "{node.label}" } h3 { "{node.label}" }
button { button { class: "close-btn", onclick: move |_| on_close.call(()), "\u{2715}" }
class: "close-btn",
onclick: move |_| on_close.call(()),
"\u{2715}"
}
} }
div { class: "node-details-content", div { class: "node-details-content",
if let Some(ref title) = node.title { if let Some(ref title) = node.title {

View file

@ -227,11 +227,55 @@ fn sanitize_html(html: &str) -> String {
// Allow common markdown elements // Allow common markdown elements
let allowed_tags: HashSet<&str> = [ let allowed_tags: HashSet<&str> = [
"a", "abbr", "acronym", "b", "blockquote", "br", "code", "dd", "del", "a",
"details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "abbr",
"hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "q", "acronym",
"s", "samp", "small", "span", "strong", "sub", "summary", "sup", "b",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul", "var", "blockquote",
"br",
"code",
"dd",
"del",
"details",
"div",
"dl",
"dt",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"ins",
"kbd",
"li",
"mark",
"ol",
"p",
"pre",
"q",
"s",
"samp",
"small",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"u",
"ul",
"var",
// Task list support // Task list support
"input", "input",
] ]
@ -240,13 +284,27 @@ fn sanitize_html(html: &str) -> String {
// Allow safe attributes // Allow safe attributes
let allowed_attrs: HashSet<&str> = [ let allowed_attrs: HashSet<&str> = [
"href", "src", "alt", "title", "class", "id", "name", "href",
"width", "height", "align", "valign", "src",
"colspan", "rowspan", "scope", "alt",
"title",
"class",
"id",
"name",
"width",
"height",
"align",
"valign",
"colspan",
"rowspan",
"scope",
// Data attributes for wikilinks (safe - no code execution) // Data attributes for wikilinks (safe - no code execution)
"data-target", "data-wikilink-target", "data-target",
"data-wikilink-target",
// Task list checkbox support // Task list checkbox support
"type", "checked", "disabled", "type",
"checked",
"disabled",
] ]
.into_iter() .into_iter()
.collect(); .collect();