Compare commits

...

29 Commits

Author SHA1 Message Date
Tomáš Mládek 1b1914f351 (cargo-release) start next development iteration 0.10.4-alpha.0 2021-10-06 09:14:54 +02:00
Tomáš Mládek e4dd5954b4 (cargo-release) version 0.10.3 2021-10-06 09:14:54 +02:00
Tomáš Mládek 0a6fcba373 hide tags used only once 2021-10-06 09:14:44 +02:00
Tomáš Mládek 7ee85e5c62 (cargo-release) start next development iteration 0.10.3-alpha.0 2021-10-05 23:12:37 +02:00
Tomáš Mládek 7e4659cfed (cargo-release) version 0.10.2 2021-10-05 23:12:37 +02:00
Tomáš Mládek 703b8b1388 increase line height 2021-10-05 23:10:54 +02:00
Tomáš Mládek e90d3c8807 include inter from cdn, enable disambiguation 2021-10-05 23:08:34 +02:00
Tomáš Mládek 0b1c2e29a5 (cargo-release) start next development iteration 0.10.2-alpha.0 2021-10-05 22:43:59 +02:00
Tomáš Mládek 162edb25a9 (cargo-release) version 0.10.1 2021-10-05 22:43:59 +02:00
Tomáš Mládek 86b414262f do not use fugly urlencoded tag in sidebar 2021-10-05 22:39:07 +02:00
Tomáš Mládek 71ce442f64 (cargo-release) start next development iteration 0.10.1-alpha.0 2021-10-05 22:35:43 +02:00
Tomáš Mládek 188548900c (cargo-release) version 0.10.0 2021-10-05 22:35:43 +02:00
Tomáš Mládek f5d1d02e65 fix clippy 2021-10-05 22:35:34 +02:00
Tomáš Mládek a2debf5b2c add tag display to sidebar 2021-10-05 22:34:29 +02:00
Tomáš Mládek f5d6c9c05b rewrite to actually use pulldown idiomatically 2021-10-04 21:23:04 +02:00
Tomáš Mládek bca533c701 (cargo-release) start next development iteration 0.9.1-alpha.0 2021-10-04 20:41:47 +02:00
Tomáš Mládek 676996ab62 (cargo-release) version 0.9.0 2021-10-04 20:41:47 +02:00
Tomáš Mládek 2605ae1fbe parse #tags as links (resolve #8) 2021-10-04 20:37:50 +02:00
Tomáš Mládek 976610d06f fix clippy 2021-10-04 20:32:35 +02:00
Tomáš Mládek fe8046cf58 anchors correspond to headline depth 2021-10-04 20:27:44 +02:00
Tomáš Mládek ee005cb4e2 (cargo-release) start next development iteration 0.8.2-alpha.0 2021-07-25 17:31:27 +02:00
Tomáš Mládek 5b9218b142 (cargo-release) version 0.8.1 2021-07-25 17:31:27 +02:00
Tomáš Mládek 850e65b5df fix link detection 2021-07-25 17:31:18 +02:00
Tomáš Mládek 526b5a5507 (cargo-release) start next development iteration 0.8.1-alpha.0 2021-07-25 17:09:05 +02:00
Tomáš Mládek 0d71ca8e2f (cargo-release) version 0.8.0 2021-07-25 17:09:05 +02:00
Tomáš Mládek 7634899fbb add rudimentary d3.js graph view 2021-07-25 17:08:34 +02:00
Tomáš Mládek 68279afeeb prettier tuple handling 2021-07-25 16:49:39 +02:00
Tomáš Mládek 4eb1ae5402 comments 2021-07-25 14:36:12 +02:00
Tomáš Mládek 2bfd9170c5 (cargo-release) start next development iteration 0.7.1-alpha.0 2021-07-25 13:58:38 +02:00
7 changed files with 308 additions and 69 deletions

4
Cargo.lock generated
View File

@ -1,5 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "actix"
version = "0.10.0"
@ -978,7 +980,7 @@ dependencies = [
[[package]]
name = "gardenserver"
version = "0.7.0"
version = "0.10.4-alpha.0"
dependencies = [
"actix",
"actix-files",

View File

@ -1,6 +1,6 @@
[package]
name = "gardenserver"
version = "0.7.0"
version = "0.10.4-alpha.0"
authors = ["Tomáš Mládek <t@mldk.cz>"]
edition = "2018"
publish = false

View File

@ -14,6 +14,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode};
use pulldown_cmark::{html, Event, Options, Parser, Tag};
use regex::{Captures, Regex};
use slug::slugify;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
@ -44,6 +45,7 @@ struct MutableState {
struct GardenCache {
pages: HashMap<String, ParsedPage>,
files: Vec<PathBuf>,
tags: HashMap<String, u32>,
}
impl Default for GardenCache {
@ -51,6 +53,7 @@ impl Default for GardenCache {
GardenCache {
pages: HashMap::new(),
files: vec![],
tags: HashMap::new(),
}
}
}
@ -194,10 +197,12 @@ async fn render(
.finish());
}
// If the path is not a markdown file (e.g. photos), just return it as it is.
if full_path.exists() && !path.ends_with(".md") {
return Ok(NamedFile::open(full_path)?.into_response(&request)?);
return NamedFile::open(full_path)?.into_response(&request);
}
// Otherwise, retrieve it and check backlinks
let filename = full_path
.components()
.last()
@ -208,17 +213,6 @@ async fn render(
let normalized_name = normalize_name(filename);
let mut recently_changed = cache
.pages
.clone()
.into_iter()
.filter_map(|i| match i.1.timestamp {
Some(ts) => Some((i.0, SystemTime::now().duration_since(ts).unwrap())),
None => None,
})
.collect::<Vec<(String, Duration)>>();
recently_changed.sort_by_key(|i| i.1);
let mut backlinks: Vec<String> = vec![];
for (path, page) in cache.pages.iter() {
if page
@ -230,10 +224,75 @@ async fn render(
}
}
let page = cache.pages.get(path.as_ref());
// Special case - graph view
let mut graph_page = ParsedPage {
timestamp: None,
title: "".to_string(),
html: "".to_string(),
links: vec![],
};
let page = if path.as_str() != "!graph" {
cache.pages.get(path.as_ref())
} else {
let mut context = Context::new();
let mut nodes: Vec<HashMap<String, String>> = vec![];
let mut links: Vec<HashMap<String, String>> = vec![];
let page_ids: Vec<String> = cache.pages.keys().map(|n| normalize_name(n)).collect();
cache.pages.iter().for_each(|(path, page)| {
let normalized_path = normalize_name(path);
nodes.push(
[("id".to_string(), normalized_path.clone())]
.iter()
.cloned()
.collect(),
);
page.links
.iter()
.map(|l| normalize_name(l))
.filter(|link| page_ids.contains(link))
.for_each(|link| {
links.push(
[
("source".to_string(), normalized_path.clone()),
("target".to_string(), link),
]
.iter()
.cloned()
.collect(),
)
})
});
context.insert("nodes", &nodes);
context.insert("links", &links);
graph_page.title = "Graph View".to_string();
graph_page.html = data
.tera
.render("graph.html", &context)
.map_err(ErrorInternalServerError)?;
Some(&graph_page)
};
// Recently changed
let mut recently_changed = cache
.pages
.clone()
.into_iter()
.filter_map(|(path, page)| {
page.timestamp
.map(|ts| (path, SystemTime::now().duration_since(ts).unwrap()))
})
.collect::<Vec<(String, Duration)>>();
recently_changed.sort_by_key(|i| i.1);
let timeago = timeago::Formatter::new();
// Render context generation
let mut context = Context::new();
context.insert("version", VERSION);
context.insert(
@ -241,11 +300,17 @@ async fn render(
data.title.as_ref().unwrap_or(&"Digital Garden".to_string()),
);
context.insert("files", &cache.files);
let mut tags: Vec<(&String, &u32)> = cache.tags.iter().collect();
tags.sort_by_key(|(t, _)| *t);
tags.sort_by_key(|(_, n)| Reverse(*n));
context.insert("tags", &tags);
context.insert(
"recently_changed",
&recently_changed
.into_iter()
.map(|i| (i.0, timeago.convert(i.1)))
.map(|(path, duration)| (path, timeago.convert(duration)))
.collect::<Vec<(String, String)>>(),
);
context.insert(
@ -331,6 +396,7 @@ fn update_garden<P: AsRef<Path>>(
}
let mut pages = current.pages;
let mut tags = current.tags;
let markdown_paths = files
.iter()
@ -351,7 +417,7 @@ fn update_garden<P: AsRef<Path>>(
let mut file_string = String::new();
file.read_to_string(&mut file_string)?;
let markdown_source = preprocess_markdown(file_string);
let result = GardenParser::parse(&markdown_source)?;
let result = parse_garden(&markdown_source)?;
pages.insert(
String::from(path.to_str().unwrap()),
@ -372,73 +438,70 @@ fn update_garden<P: AsRef<Path>>(
},
},
);
result.tags.into_iter().for_each(|tag| {
*tags.entry(tag).or_insert(0) += 1;
});
}
let result = GardenCache { files, pages };
let result = GardenCache { pages, files, tags };
trace!("{:#?}", result);
Ok(result)
}
struct GardenParser<'a> {
parser: Parser<'a>,
last_nontext_event: Option<Event<'a>>,
current_top_heading: u32,
top_heading_text: &'a mut Option<String>,
links: &'a mut Vec<String>,
}
struct ParseResult {
html: String,
title: Option<String>,
links: Vec<String>,
tags: Vec<String>,
}
impl<'a> GardenParser<'a> {
fn parse<S: AsRef<str>>(text: &'a S) -> anyhow::Result<ParseResult> {
let mut title: Option<String> = None;
let mut links: Vec<String> = vec![];
fn parse_garden<S: AsRef<str>>(text: S) -> anyhow::Result<ParseResult> {
let mut current_top_heading = 999;
let mut top_heading_text: Option<String> = None;
let parser = GardenParser {
parser: Parser::new_ext(text.as_ref(), Options::all()),
last_nontext_event: None,
current_top_heading: 999,
top_heading_text: &mut title,
links: &mut links,
};
let mut last_nontext_event: Option<Event> = None;
let mut html = String::new();
html::push_html(&mut html, parser);
html = postprocess_html(html)?;
let mut links: Vec<String> = vec![];
let mut tags: Vec<String> = vec![];
Ok(ParseResult { html, title, links })
}
}
impl<'a> Iterator for GardenParser<'a> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
let event = self.parser.next();
if let Some(event) = &event {
if let Event::Start(Tag::Link(_, str, _)) = &event {
self.links.push(str.to_string());
}
if let Some(Event::Start(Tag::Heading(hl))) = self.last_nontext_event {
if hl < self.current_top_heading {
self.current_top_heading = hl;
if let Event::Text(str) = &event {
*self.top_heading_text = Some(str.clone().into_string());
}
}
}
self.last_nontext_event = Some(event.clone());
let parser = Parser::new_ext(text.as_ref(), Options::all()).map(|event| {
if let Event::Start(Tag::Link(_, dest, _)) = &event {
links.push(dest.to_string());
}
if let Some(Event::Start(Tag::Link(_, _, _))) = &last_nontext_event {
if let Event::Text(str) = &event {
if str.starts_with('#') {
tags.push(str[1..].to_string());
}
}
}
if let Some(Event::Start(Tag::Heading(hl))) = last_nontext_event {
if hl < current_top_heading {
current_top_heading = hl;
if let Event::Text(str) = &event {
top_heading_text = Some(str.clone().into_string());
}
}
}
last_nontext_event = Some(event.clone());
event
}
});
let mut html = String::new();
html::push_html(&mut html, parser);
html = postprocess_html(html)?;
Ok(ParseResult {
html,
title: top_heading_text,
links,
tags,
})
}
fn preprocess_markdown(string: String) -> String {
@ -446,7 +509,7 @@ fn preprocess_markdown(string: String) -> String {
let finder = LinkFinder::new();
let result = double_brackets
.replace_all(string.as_str(), |caps: &Captures| {
.replace_all(&string, |caps: &Captures| {
format!(
"[{}]({})",
&caps[1],
@ -455,6 +518,17 @@ fn preprocess_markdown(string: String) -> String {
})
.to_string();
let tags = Regex::new(r"#([\w]+)").unwrap();
let result = tags
.replace_all(&result, |caps: &Captures| {
format!(
"[{}]({})",
&caps[0],
utf8_percent_encode(&caps[1], percent_encoding::NON_ALPHANUMERIC)
)
})
.to_string();
let result_vec = Vec::from(result.as_str());
let start_delims = vec![b'(', b'<'];
let end_delims = vec![b')', b'>'];
@ -520,5 +594,5 @@ fn postprocess_html<T: AsRef<str>>(document: T) -> anyhow::Result<String> {
fn normalize_name(filename: &str) -> String {
let decoded = percent_decode_str(filename).decode_utf8_lossy();
let result = decoded.strip_suffix(".md");
String::from(result.unwrap_or(decoded.as_ref()))
String::from(result.unwrap_or_else(|| decoded.as_ref()))
}

21
templates/graph.html Normal file
View File

@ -0,0 +1,21 @@
<div id="graph">
</div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-dispatch@3"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-quadtree@3"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-timer@3"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-force@3"></script>
<script>
const nodes = {{nodes | json_encode() | safe}};
const links = {{links | json_encode() | safe}};
</script>
<script src="/static/graph.js"></script>
<style>
#graph {
width: 100%;
height: 100vh;
}
</style>

87
templates/graph.js Normal file
View File

@ -0,0 +1,87 @@
// const nodes = [
// {"id": "Alice"},
// {"id": "Bob"},
// {"id": "Carol"}
// ];
//
// const links = [
// {"source": 0, "target": 1}, // Alice → Bob
// {"source": 1, "target": 0} // Bob → Carol
// ];
const graphEl = document.getElementById("graph");
const drag = (simulation) => {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("x", d3.forceX())
.force("y", d3.forceY());
setTimeout(() => {
const width = graphEl.clientWidth;
const height = graphEl.clientHeight;
const svg = d3.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, height]);
const link = svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line")
.attr("stroke-width", d => Math.sqrt(d.value));
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("r", 5)
.attr("fill", "red")
.call(drag(simulation));
node.append("title")
.text(d => d.id);
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
graphEl.appendChild(svg.node());
}, 0);

View File

@ -1,5 +1,15 @@
body {
@import url('https://rsms.me/inter/inter.css');
@supports (font-variation-settings: normal) {
html {
font-family: 'Inter var', sans-serif;
font-feature-settings: "frac", "cpsp", "ss02", "ss03";
}
}
html {
font-family: 'Inter', sans-serif;
line-height: 1.2;
}
h1, h2, h3, h4, h5 {
@ -58,6 +68,23 @@ li p {
.anchor:after {
content: "#";
margin-right: .15em;
font-size: 80%;
}
h2 .anchor:after {
content: "##";
}
h3 .anchor:after {
content: "###";
}
h4 .anchor:after {
content: "####";
}
h5 .anchor:after {
content: "#####";
}
.anchor:visited {
@ -162,6 +189,14 @@ nav .timestamp:after {
content: "]";
}
nav .graph-view {
text-align: center;
}
.tag .count {
opacity: .5;
}
footer {
padding: 1em 0;
color: gray;

View File

@ -19,6 +19,7 @@
<meta property="og:image" content="/static/favicon.png" />
<link rel="shortcut icon" href="/static/favicon.png" type="image/x-icon">
<link href="/static/main.css" rel="stylesheet">
<link rel="stylesheet" href="main.css">
</head>
<body>
@ -72,6 +73,25 @@
{% endfor %}
</ul>
</section>
{% if tags %}
<section>
<h2>Tags</h2>
<ul>
{% for tag_cnt in tags %}
{% if tag_cnt.1 > 1 %}
<li class="tag">
<a href="/{{tag_cnt.0}}">
<span class="label">#{{tag_cnt.0}}</span> <span class="count">({{tag_cnt.1}})</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</section>
{% endif %}
<section class="graph-view">
<a href="/!graph">Graph view (beta)</a>
</section>
</nav>
<footer><a href="https://gitlab.com/tmladek/gardenserver">gardenserver {{version}}</a></footer>
</aside>