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:
parent
0f9c1383a9
commit
3e1e8dea26
5 changed files with 103 additions and 43 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,16 +200,13 @@ 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",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw nodes
|
// Draw nodes
|
||||||
g { class: "graph-nodes",
|
g { class: "graph-nodes",
|
||||||
for (i, node) in nodes.iter().enumerate() {
|
for (i , node) in nodes.iter().enumerate() {
|
||||||
{
|
{
|
||||||
let (x, y) = positions[i];
|
let (x, y) = positions[i];
|
||||||
let node_id = node.id.clone();
|
let node_id = node.id.clone();
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue