various: markdown improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
raf 2026-02-05 15:39:05 +03:00
commit 80a8b5c7ca
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
23 changed files with 3458 additions and 30 deletions

View file

@ -0,0 +1,345 @@
//! Backlinks panel component for showing incoming links to a note.
use dioxus::prelude::*;
use crate::client::{ApiClient, BacklinkItem, BacklinksResponse};
/// Panel displaying backlinks (incoming links) to a media item.
#[component]
pub fn BacklinksPanel(
media_id: String,
client: ApiClient,
on_navigate: EventHandler<String>,
) -> Element {
let mut backlinks = use_signal(|| Option::<BacklinksResponse>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
let mut collapsed = use_signal(|| false);
let mut reindexing = use_signal(|| false);
let mut reindex_message = use_signal(|| Option::<(String, bool)>::None); // (message, is_error)
// Fetch backlinks function
let fetch_backlinks = {
let client = client.clone();
let id = media_id.clone();
move || {
let client = client.clone();
let id = id.clone();
spawn(async move {
loading.set(true);
error.set(None);
match client.get_backlinks(&id).await {
Ok(resp) => {
backlinks.set(Some(resp));
}
Err(e) => {
error.set(Some(format!("Failed to load backlinks: {e}")));
}
}
loading.set(false);
});
}
};
// Fetch backlinks on mount
let fetch_on_mount = fetch_backlinks.clone();
use_effect(move || {
fetch_on_mount();
});
// Reindex links handler
let on_reindex = {
let client = client.clone();
let id = media_id.clone();
let fetch_backlinks = fetch_backlinks.clone();
move |evt: MouseEvent| {
evt.stop_propagation(); // Don't toggle collapse
let client = client.clone();
let id = id.clone();
let fetch_backlinks = fetch_backlinks.clone();
spawn(async move {
reindexing.set(true);
reindex_message.set(None);
match client.reindex_links(&id).await {
Ok(resp) => {
reindex_message.set(Some((
format!("Reindexed: {} links extracted", resp.links_extracted),
false,
)));
// Refresh backlinks after reindex
fetch_backlinks();
}
Err(e) => {
reindex_message.set(Some((format!("Reindex failed: {e}"), true)));
}
}
reindexing.set(false);
});
}
};
let is_loading = *loading.read();
let is_collapsed = *collapsed.read();
let is_reindexing = *reindexing.read();
let backlink_data = backlinks.read();
let count = backlink_data.as_ref().map(|b| b.count).unwrap_or(0);
rsx! {
div { class: "backlinks-panel",
// Header with toggle
div {
class: "backlinks-header",
onclick: move |_| {
let current = *collapsed.read();
collapsed.set(!current);
},
span { class: "backlinks-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" }
}
span { class: "backlinks-title", "Backlinks" }
span { class: "backlinks-count", "({count})" }
// Reindex button
button {
class: "backlinks-reindex-btn",
title: "Re-extract links from this note",
disabled: is_reindexing,
onclick: on_reindex,
if is_reindexing {
span { class: "spinner-tiny" }
} else {
"\u{21bb}" // Refresh symbol
}
}
}
if !is_collapsed {
div { class: "backlinks-content",
// Show reindex message if present
if let Some((ref msg, is_err)) = *reindex_message.read() {
div {
class: if is_err { "backlinks-message error" } else { "backlinks-message success" },
"{msg}"
}
}
if is_loading {
div { class: "backlinks-loading",
div { class: "spinner-small" }
"Loading backlinks..."
}
}
if let Some(ref err) = *error.read() {
div { class: "backlinks-error", "{err}" }
}
if !is_loading && error.read().is_none() {
if let Some(ref data) = *backlink_data {
if data.backlinks.is_empty() {
div { class: "backlinks-empty",
"No other notes link to this one."
}
} else {
ul { class: "backlinks-list",
for backlink in &data.backlinks {
BacklinkItemView {
backlink: backlink.clone(),
on_navigate: on_navigate.clone(),
}
}
}
}
}
}
}
}
}
}
}
/// Individual backlink item view.
#[component]
fn BacklinkItemView(
backlink: BacklinkItem,
on_navigate: EventHandler<String>,
) -> Element {
let source_id = backlink.source_id.clone();
let title = backlink
.source_title
.clone()
.unwrap_or_else(|| backlink.source_path.clone());
let context = backlink.context.clone();
let line_number = backlink.line_number;
let link_type = backlink.link_type.clone();
rsx! {
li {
class: "backlink-item",
onclick: move |_| on_navigate.call(source_id.clone()),
div { class: "backlink-source",
span { class: "backlink-title", "{title}" }
span { class: "backlink-type-badge backlink-type-{link_type}", "{link_type}" }
}
if let Some(ref ctx) = context {
div { class: "backlink-context",
if let Some(ln) = line_number {
span { class: "backlink-line", "L{ln}: " }
}
"\"{ctx}\""
}
}
}
}
}
/// Outgoing links panel showing what this note links to.
#[component]
pub fn OutgoingLinksPanel(
media_id: String,
client: ApiClient,
on_navigate: EventHandler<String>,
) -> Element {
let mut links = use_signal(|| Option::<crate::client::OutgoingLinksResponse>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
let mut collapsed = use_signal(|| true); // Collapsed by default
let mut global_unresolved = use_signal(|| Option::<u64>::None);
// Fetch outgoing links on mount
let id = media_id.clone();
let client_clone = client.clone();
use_effect(move || {
let id = id.clone();
let client = client_clone.clone();
spawn(async move {
loading.set(true);
error.set(None);
match client.get_outgoing_links(&id).await {
Ok(resp) => {
links.set(Some(resp));
}
Err(e) => {
error.set(Some(format!("Failed to load links: {e}")));
}
}
loading.set(false);
// Also fetch global unresolved count
if let Ok(count) = client.get_unresolved_links_count().await {
global_unresolved.set(Some(count));
}
});
});
let is_loading = *loading.read();
let is_collapsed = *collapsed.read();
let link_data = links.read();
let count = link_data.as_ref().map(|l| l.count).unwrap_or(0);
let unresolved_in_note = link_data
.as_ref()
.map(|l| l.links.iter().filter(|link| !link.is_resolved).count())
.unwrap_or(0);
rsx! {
div { class: "outgoing-links-panel",
// Header with toggle
div {
class: "outgoing-links-header",
onclick: move |_| {
let current = *collapsed.read();
collapsed.set(!current);
},
span { class: "outgoing-links-toggle",
if is_collapsed { "\u{25b6}" } else { "\u{25bc}" }
}
span { class: "outgoing-links-title", "Outgoing Links" }
span { class: "outgoing-links-count", "({count})" }
if unresolved_in_note > 0 {
span {
class: "outgoing-links-unresolved-badge",
title: "Unresolved links in this note",
"{unresolved_in_note} unresolved"
}
}
}
if !is_collapsed {
div { class: "outgoing-links-content",
if is_loading {
div { class: "outgoing-links-loading",
div { class: "spinner-small" }
"Loading links..."
}
}
if let Some(ref err) = *error.read() {
div { class: "outgoing-links-error", "{err}" }
}
if !is_loading && error.read().is_none() {
if let Some(ref data) = *link_data {
if data.links.is_empty() {
div { class: "outgoing-links-empty",
"This note has no outgoing links."
}
} else {
ul { class: "outgoing-links-list",
for link in &data.links {
OutgoingLinkItemView {
link: link.clone(),
on_navigate: on_navigate.clone(),
}
}
}
}
}
// Show global unresolved count if any
if let Some(global_count) = *global_unresolved.read() {
if global_count > 0 {
div { class: "outgoing-links-global-unresolved",
span { class: "unresolved-icon", "\u{26a0}" }
" {global_count} unresolved links across all notes"
}
}
}
}
}
}
}
}
}
/// Individual outgoing link item view.
#[component]
fn OutgoingLinkItemView(
link: crate::client::OutgoingLinkItem,
on_navigate: EventHandler<String>,
) -> Element {
let target_id = link.target_id.clone();
let target_path = link.target_path.clone();
let link_text = link.link_text.clone();
let is_resolved = link.is_resolved;
let link_type = link.link_type.clone();
let display_text = link_text.unwrap_or_else(|| target_path.clone());
let resolved_class = if is_resolved { "resolved" } else { "unresolved" };
rsx! {
li {
class: "outgoing-link-item {resolved_class}",
onclick: move |_| {
if let Some(ref id) = target_id {
on_navigate.call(id.clone());
}
},
div { class: "outgoing-link-target",
span { class: "outgoing-link-text", "{display_text}" }
span { class: "outgoing-link-type-badge link-type-{link_type}", "{link_type}" }
if !is_resolved {
span { class: "unresolved-badge", "unresolved" }
}
}
}
}
}

View file

@ -1,5 +1,6 @@
use dioxus::prelude::*;
use super::backlinks_panel::{BacklinksPanel, OutgoingLinksPanel};
use super::image_viewer::ImageViewer;
use super::markdown_viewer::MarkdownViewer;
use super::media_player::{MediaPlayer, PlayQueue, QueueItem, QueuePanel};
@ -23,6 +24,7 @@ pub fn Detail(
on_set_custom_field: EventHandler<(String, String, String, String)>,
on_delete_custom_field: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
#[props(default)] on_navigate_to_media: Option<EventHandler<String>>,
#[props(default)] on_queue_select: Option<EventHandler<usize>>,
#[props(default)] on_queue_remove: Option<EventHandler<usize>>,
#[props(default)] on_queue_clear: Option<EventHandler<()>>,
@ -751,6 +753,43 @@ pub fn Detail(
}
}
// Backlinks and outgoing links panels for markdown/text files
if category == "text" {
{
let client_for_backlinks = client.clone();
let client_for_outgoing = client.clone();
let media_id_for_backlinks = id.clone();
let media_id_for_outgoing = id.clone();
let nav_handler = on_navigate_to_media;
rsx! {
BacklinksPanel {
media_id: media_id_for_backlinks,
client: client_for_backlinks,
on_navigate: {
let handler = nav_handler;
move |target_id: String| {
if let Some(ref h) = handler {
h.call(target_id);
}
}
},
}
OutgoingLinksPanel {
media_id: media_id_for_outgoing,
client: client_for_outgoing,
on_navigate: {
let handler = nav_handler;
move |target_id: String| {
if let Some(ref h) = handler {
h.call(target_id);
}
}
},
}
}
}
}
// Image viewer overlay
if *show_image_viewer.read() {
ImageViewer {

View file

@ -0,0 +1,295 @@
//! Graph visualization component for markdown note connections.
//!
//! Renders a force-directed graph showing connections between notes.
//! Uses a simple SVG-based rendering approach (no D3.js dependency).
use dioxus::prelude::*;
use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse};
/// Graph view component showing note connections.
#[component]
pub fn GraphView(
client: ApiClient,
center_id: Option<String>,
on_navigate: EventHandler<String>,
) -> Element {
let mut graph_data = use_signal(|| Option::<GraphResponse>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
let mut depth = use_signal(|| 2u32);
let mut selected_node = use_signal(|| Option::<String>::None);
// Fetch graph data
let center = center_id.clone();
let d = *depth.read();
let client_clone = client.clone();
use_effect(move || {
let center = center.clone();
let client = client_clone.clone();
spawn(async move {
loading.set(true);
error.set(None);
match client.get_graph(center.as_deref(), Some(d)).await {
Ok(resp) => {
graph_data.set(Some(resp));
}
Err(e) => {
error.set(Some(format!("Failed to load graph: {e}")));
}
}
loading.set(false);
});
});
let is_loading = *loading.read();
let current_depth = *depth.read();
let data = graph_data.read();
rsx! {
div { class: "graph-view",
// Toolbar
div { class: "graph-toolbar",
span { class: "graph-title", "Note Graph" }
div { class: "graph-controls",
label { "Depth: " }
select {
value: "{current_depth}",
onchange: move |evt| {
if let Ok(d) = evt.value().parse::<u32>() {
depth.set(d);
}
},
option { value: "1", "1" }
option { value: "2", "2" }
option { value: "3", "3" }
option { value: "4", "4" }
option { value: "5", "5" }
}
}
if let Some(ref data) = *data {
div { class: "graph-stats",
"{data.node_count} nodes, {data.edge_count} edges"
}
}
}
// Graph container
div { class: "graph-container",
if is_loading {
div { class: "graph-loading",
div { class: "spinner" }
"Loading graph..."
}
}
if let Some(ref err) = *error.read() {
div { class: "graph-error", "{err}" }
}
if !is_loading && error.read().is_none() {
if let Some(ref graph) = *data {
if graph.nodes.is_empty() {
div { class: "graph-empty",
"No linked notes found. Start creating links between your notes!"
}
} else {
GraphSvg {
nodes: graph.nodes.clone(),
edges: graph.edges.clone(),
selected_node: selected_node.clone(),
on_node_click: move |id: String| {
selected_node.set(Some(id.clone()));
},
on_node_double_click: move |id: String| {
on_navigate.call(id);
},
}
}
}
}
}
// Node details panel
if let Some(ref node_id) = *selected_node.read() {
if let Some(ref graph) = *data {
if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) {
NodeDetailsPanel {
node: node.clone(),
on_close: move |_| selected_node.set(None),
on_navigate: move |id| {
on_navigate.call(id);
},
}
}
}
}
}
}
}
/// SVG-based graph rendering.
#[component]
fn GraphSvg(
nodes: Vec<GraphNodeResponse>,
edges: Vec<GraphEdgeResponse>,
selected_node: Signal<Option<String>>,
on_node_click: EventHandler<String>,
on_node_double_click: EventHandler<String>,
) -> Element {
// Simple circular layout for nodes
let node_count = nodes.len();
let width: f64 = 800.0;
let height: f64 = 600.0;
let center_x = width / 2.0;
let center_y = height / 2.0;
let radius = (width.min(height) / 2.0) - 60.0;
// Calculate node positions in a circle
let positions: Vec<(f64, f64)> = (0..node_count)
.map(|i| {
let angle = (i as f64 / node_count as f64) * 2.0 * std::f64::consts::PI;
let x = center_x + radius * angle.cos();
let y = center_y + radius * angle.sin();
(x, y)
})
.collect();
// Create a map from node id to position
let id_to_pos: std::collections::HashMap<&str, (f64, f64)> = nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), positions[i]))
.collect();
let selected = selected_node.read();
rsx! {
svg {
class: "graph-svg",
width: "{width}",
height: "{height}",
view_box: "0 0 {width} {height}",
// Draw edges first (so they appear behind nodes)
g { class: "graph-edges",
for edge in &edges {
if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
id_to_pos.get(edge.source.as_str()),
id_to_pos.get(edge.target.as_str())
) {
line {
class: "graph-edge edge-type-{edge.link_type}",
x1: "{x1}",
y1: "{y1}",
x2: "{x2}",
y2: "{y2}",
stroke: "#888",
stroke_width: "1",
marker_end: "url(#arrowhead)",
}
}
}
}
// Arrow marker definition
defs {
marker {
id: "arrowhead",
marker_width: "10",
marker_height: "7",
ref_x: "10",
ref_y: "3.5",
orient: "auto",
polygon {
points: "0 0, 10 3.5, 0 7",
fill: "#888",
}
}
}
// Draw nodes
g { class: "graph-nodes",
for (i, node) in nodes.iter().enumerate() {
{
let (x, y) = positions[i];
let node_id = node.id.clone();
let node_id2 = node.id.clone();
let label = node.label.clone();
let is_selected = selected.as_ref() == Some(&node.id);
let node_size = 8.0 + (node.link_count + node.backlink_count) as f64 * 2.0;
let node_size = node_size.min(30.0);
rsx! {
g {
class: if is_selected { "graph-node selected" } else { "graph-node" },
onclick: move |_| on_node_click.call(node_id.clone()),
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()),
circle {
cx: "{x}",
cy: "{y}",
r: "{node_size}",
fill: if is_selected { "#2196f3" } else { "#4caf50" },
stroke: if is_selected { "#1565c0" } else { "#388e3c" },
stroke_width: "2",
}
text {
x: "{x}",
y: "{y + node_size + 15.0}",
text_anchor: "middle",
font_size: "12",
fill: "#333",
"{label}"
}
}
}
}
}
}
}
}
}
/// Panel showing details about the selected node.
#[component]
fn NodeDetailsPanel(
node: GraphNodeResponse,
on_close: EventHandler<()>,
on_navigate: EventHandler<String>,
) -> Element {
let node_id = node.id.clone();
rsx! {
div { class: "node-details-panel",
div { class: "node-details-header",
h3 { "{node.label}" }
button {
class: "close-btn",
onclick: move |_| on_close.call(()),
"\u{2715}"
}
}
div { class: "node-details-content",
if let Some(ref title) = node.title {
p { class: "node-title", "{title}" }
}
div { class: "node-stats",
span { class: "stat",
"Outgoing: "
strong { "{node.link_count}" }
}
span { class: "stat",
"Incoming: "
strong { "{node.backlink_count}" }
}
}
button {
class: "btn btn-primary",
onclick: move |_| on_navigate.call(node_id.clone()),
"Open Note"
}
}
}
}
}

View file

@ -1,7 +1,14 @@
use dioxus::prelude::*;
/// Event handler for wikilink clicks. Called with the target note name.
pub type WikilinkClickHandler = EventHandler<String>;
#[component]
pub fn MarkdownViewer(content_url: String, media_type: String) -> Element {
pub fn MarkdownViewer(
content_url: String,
media_type: String,
#[props(default)] on_wikilink_click: Option<WikilinkClickHandler>,
) -> Element {
let mut rendered_html = use_signal(String::new);
let mut frontmatter_html = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
@ -133,6 +140,9 @@ fn pod_to_display(pod: &gray_matter::Pod) -> String {
fn render_markdown(text: &str) -> String {
use pulldown_cmark::{Options, Parser, html};
// First, convert wikilinks to standard markdown links
let text_with_links = convert_wikilinks(text);
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
@ -140,12 +150,47 @@ fn render_markdown(text: &str) -> String {
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let parser = Parser::new_ext(text, options);
let parser = Parser::new_ext(&text_with_links, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
// Strip script tags for safety
strip_script_tags(&html_output)
// Sanitize HTML using ammonia with a safe allowlist
sanitize_html(&html_output)
}
/// Convert wikilinks [[target]] and [[target|display]] to styled HTML links.
/// Uses data attributes only - no inline JavaScript for security.
fn convert_wikilinks(text: &str) -> String {
use regex::Regex;
// Match embeds ![[target]] first, convert to a placeholder image/embed span
let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
let text = embed_re.replace_all(text, |caps: &regex::Captures| {
let target = caps.get(1).unwrap().as_str().trim();
let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
format!(
"<span class=\"wikilink-embed\" data-target=\"{}\" title=\"Embed: {}\">[Embed: {}]</span>",
escape_html_attr(target),
escape_html_attr(target),
escape_html(alt)
)
});
// Match wikilinks [[target]] or [[target|display]]
let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
let text = wikilink_re.replace_all(&text, |caps: &regex::Captures| {
let target = caps.get(1).unwrap().as_str().trim();
let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
// Create a styled link with data attributes only - no inline JavaScript.
// Event handling is done via event delegation in the frontend.
format!(
"<a href=\"#wikilink\" class=\"wikilink\" data-wikilink-target=\"{}\">{}</a>",
escape_html_attr(target),
escape_html(display)
)
});
text.to_string()
}
fn render_plaintext(text: &str) -> String {
@ -153,6 +198,7 @@ fn render_plaintext(text: &str) -> String {
format!("<pre><code>{escaped}</code></pre>")
}
/// Escape text for display in HTML content.
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
@ -160,21 +206,59 @@ fn escape_html(text: &str) -> String {
.replace('"', "&quot;")
}
fn strip_script_tags(html: &str) -> String {
// Simple removal of <script> tags
let mut result = html.to_string();
while let Some(start) = result.to_lowercase().find("<script") {
if let Some(end) = result.to_lowercase()[start..].find("</script>") {
result = format!(
"{}{}",
&result[..start],
&result[start + end + "</script>".len()..]
);
} else {
// Malformed script tag - remove to end
result = result[..start].to_string();
break;
}
}
result
/// Escape text for use in HTML attributes (includes single quotes).
fn escape_html_attr(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
/// Sanitize HTML using ammonia with a safe allowlist.
/// This prevents XSS attacks by removing dangerous elements and attributes.
fn sanitize_html(html: &str) -> String {
use ammonia::Builder;
use std::collections::HashSet;
// Build a custom sanitizer that allows safe markdown elements
// but strips all event handlers and dangerous elements
let mut builder = Builder::default();
// Allow common markdown elements
let allowed_tags: HashSet<&str> = [
"a", "abbr", "acronym", "b", "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
"input",
]
.into_iter()
.collect();
// Allow safe attributes
let allowed_attrs: HashSet<&str> = [
"href", "src", "alt", "title", "class", "id", "name",
"width", "height", "align", "valign",
"colspan", "rowspan", "scope",
// Data attributes for wikilinks (safe - no code execution)
"data-target", "data-wikilink-target",
// Task list checkbox support
"type", "checked", "disabled",
]
.into_iter()
.collect();
builder
.tags(allowed_tags)
.generic_attributes(allowed_attrs)
// Allow relative URLs and fragment-only URLs for internal links
.url_schemes(["http", "https", "mailto"].into_iter().collect())
.link_rel(Some("noopener noreferrer"))
// Strip all event handler attributes (onclick, onerror, etc.)
.strip_comments(true)
.clean(html)
.to_string()
}

View file

@ -1,9 +1,11 @@
pub mod audit;
pub mod backlinks_panel;
pub mod breadcrumb;
pub mod collections;
pub mod database;
pub mod detail;
pub mod duplicates;
pub mod graph_view;
pub mod image_viewer;
pub mod import;
pub mod library;