Week 04 of 2024

Development log of Tad Notes

24 items
  1. Always run result recipe from the makefile
  2. Separate Note type and helpers to own module
  3. Decouple note module from imap
  4. Move markdown conversion to the ToMarkdown trait
  5. Separate imap_import module
  6. Setup a CLI with import and export subcommands
  7. Implement the list command
  8. Qualify clap imports
  9. Creates a Notes collection
  10. Let the import take path as a CLI parameter
  11. WIP: Export - implement finding links in notes
  12. Use identifier instead of date for Note::identifier
  13. WIP: Make the export command print backlinks
  14. Implement and use getters for note metadata
  15. Use template to print html output from export
  16. The export command will write html files
  17. Implement html extraction from a multipart messages
  18. Specify language (en) for exported notes
  19. Account for lack of backlinks in the templates
  20. Improve some error messages
  21. The program will write index.html
  22. Remove obsolete variable from Makefile
  23. Experiment: prefix all internal links with ./
  24. Support import of text when HTML is not found

Always run result recipe from the makefile

On by Tad Lispy

If there is no change to the code, then nix makes it very fast. If there is any change, the build should run again.

index 5aee1ac..d3fe861 100644
--- a/Makefile
+++ b/Makefile
@@ -59,6 +59,7 @@ result: ## Build the program using Nix
=result:
=	cachix use $(nix-cache-name)
=	$(nix) build --print-build-logs
+.PHONY: result
=
=nix-cache: ## Push Nix binary cache to Cachix
=nix-cache: result

Separate Note type and helpers to own module

On by Tad Lispy

Further separation is probably needed for email to note logic.

index 03d1eec..5e14423 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,17 +1,12 @@
-use askama::Template;
-use chrono::DateTime;
-use chrono::Utc;
+mod note;
+
=use env_logger;
=use glob::glob;
=use imap;
=use log::{debug, info};
-use mailparse::parse_mail;
-use mailparse::MailHeaderMap;
=use native_tls;
=use native_tls::TlsStream;
-use pandoc;
-use pandoc::Pandoc;
-use slug::slugify;
+use note::Note;
=use std::env;
=use std::fs;
=use std::net::TcpStream;
@@ -86,94 +81,6 @@ fn main() {
=    info!("Done.");
=}
=
-struct Note {
-    title: String,
-    timestamp: DateTime<Utc>,
-    content: String,
-}
-
-impl Note {
-    // TODO: Return a result?
-    fn from_email_message(message: &imap::types::Fetch) -> Self {
-        let body = message.body().unwrap();
-        let parsed = parse_mail(body).unwrap();
-
-        let date = parsed
-            .headers
-            .get_first_value("Date")
-            .expect("No date in the message header");
-
-        let timestamp = chrono::DateTime::parse_from_rfc2822(&date)
-            .expect("Failed to parse the date {date} (expected RFC2822 format)");
-
-        let text = parsed
-            .get_body()
-            .expect("Failed to extract body from the message");
-
-        let content = content_to_markdown(text);
-
-        Self {
-            title: parsed.headers.get_first_value("Subject").unwrap(),
-            timestamp: timestamp.into(),
-            content,
-        }
-    }
-
-    fn to_markdown(&self) -> String {
-        let markdown = MarkdownNoteTemplate {
-            title: self.title.clone(),
-            date: self.timestamp,
-            identifier: self.identifier(),
-            content: self.content.clone(),
-            // TODO: Support tags
-        };
-        markdown
-            .render()
-            .expect("Failed to render the note as markdown")
-    }
-
-    fn identifier(&self) -> String {
-        self.timestamp.format("%Y%m%dT%H%M%S").to_string()
-    }
-
-    fn filename(&self) -> String {
-        // TODO: Support tags in file name.
-        // NOTE: If tags are present, then after slug there is __ and then tags separated by _.
-        format!(
-            "{identifier}--{slug}.md",
-            identifier = self.identifier(),
-            slug = slugify(&self.title),
-        )
-    }
-}
-
-fn content_to_markdown(text: String) -> String {
-    let mut pandoc = Pandoc::new();
-    pandoc.set_input(pandoc::InputKind::Pipe(text));
-    pandoc.set_output(pandoc::OutputKind::Pipe);
-    // TODO: Support other input formats, depending on the Content-Type header
-    pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
-    pandoc.set_output_format(pandoc::OutputFormat::MarkdownGithub, Vec::new());
-
-    // This is a bit awkward. Do I have to do it like that?
-    if let pandoc::PandocOutput::ToBuffer(markdown) =
-        pandoc.execute().expect("Conversion of content failed")
-    {
-        markdown
-    } else {
-        panic!("Unexpected output kind from pandoc")
-    }
-}
-
-#[derive(Template)]
-#[template(path = "note.md")]
-struct MarkdownNoteTemplate {
-    title: String,
-    date: DateTime<Utc>,
-    identifier: String,
-    content: String,
-}
-
=// TODO: Return a result. How to combine different types of errors (io, tls, imap)?
=fn imap_connect(config: &IMAPConfig) -> imap::Session<TlsStream<TcpStream>> {
=    // Setup TLS with a certificate from Proton Bridge
new file mode 100644
index 0000000..7b3ea0b
--- /dev/null
+++ b/src/note.rs
@@ -0,0 +1,96 @@
+use askama::Template;
+use chrono::DateTime;
+use chrono::Utc;
+use mailparse::parse_mail;
+use mailparse::MailHeaderMap;
+use pandoc;
+use pandoc::Pandoc;
+use slug::slugify;
+
+pub struct Note {
+    pub title: String,
+    pub timestamp: DateTime<Utc>,
+    pub content: String,
+}
+
+impl Note {
+    // TODO: Return a result?
+    pub fn from_email_message(message: &imap::types::Fetch) -> Self {
+        let body = message.body().unwrap();
+        let parsed = parse_mail(body).unwrap();
+
+        let date = parsed
+            .headers
+            .get_first_value("Date")
+            .expect("No date in the message header");
+
+        let timestamp = chrono::DateTime::parse_from_rfc2822(&date)
+            .expect("Failed to parse the date {date} (expected RFC2822 format)");
+
+        let text = parsed
+            .get_body()
+            .expect("Failed to extract body from the message");
+
+        let content = content_to_markdown(text);
+
+        Self {
+            title: parsed.headers.get_first_value("Subject").unwrap(),
+            timestamp: timestamp.into(),
+            content,
+        }
+    }
+
+    pub fn to_markdown(&self) -> String {
+        let markdown = MarkdownNoteTemplate {
+            title: self.title.clone(),
+            date: self.timestamp,
+            identifier: self.identifier(),
+            content: self.content.clone(),
+            // TODO: Support tags
+        };
+        markdown
+            .render()
+            .expect("Failed to render the note as markdown")
+    }
+
+    pub fn identifier(&self) -> String {
+        self.timestamp.format("%Y%m%dT%H%M%S").to_string()
+    }
+
+    pub fn filename(&self) -> String {
+        // TODO: Support tags in file name.
+        // NOTE: If tags are present, then after slug there is __ and then tags separated by _.
+        format!(
+            "{identifier}--{slug}.md",
+            identifier = self.identifier(),
+            slug = slugify(&self.title),
+        )
+    }
+}
+
+#[derive(Template)]
+#[template(path = "note.md")]
+struct MarkdownNoteTemplate {
+    title: String,
+    date: DateTime<Utc>,
+    identifier: String,
+    content: String,
+}
+
+fn content_to_markdown(text: String) -> String {
+    let mut pandoc = Pandoc::new();
+    pandoc.set_input(pandoc::InputKind::Pipe(text));
+    pandoc.set_output(pandoc::OutputKind::Pipe);
+    // TODO: Support other input formats, depending on the Content-Type header
+    pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
+    pandoc.set_output_format(pandoc::OutputFormat::MarkdownGithub, Vec::new());
+
+    // This is a bit awkward. Do I have to do it like that?
+    if let pandoc::PandocOutput::ToBuffer(markdown) =
+        pandoc.execute().expect("Conversion of content failed")
+    {
+        markdown
+    } else {
+        panic!("Unexpected output kind from pandoc")
+    }
+}

Decouple note module from imap

On by Tad Lispy

Let the Note::from_email_message take a parsed mail instead of imap::Fetch, so that it doesn't have to care about the origin of the message, or how it was parsed. Fetching and parsing happens in the main module now.

index 5e14423..d0627ba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ use env_logger;
=use glob::glob;
=use imap;
=use log::{debug, info};
+use mailparse::parse_mail;
=use native_tls;
=use native_tls::TlsStream;
=use note::Note;
@@ -42,7 +43,9 @@ fn main() {
=
=    let export_path = Path::new(&config.export_path);
=    for message in &messages {
-        let note = Note::from_email_message(&message);
+        let body = message.body().unwrap();
+        let parsed = parse_mail(body).unwrap();
+        let note = Note::from_email_message(&parsed);
=
=        let pattern = format!(
=            "{export_path}/{identifier}--*",
index 7b3ea0b..e5eb2c7 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -1,8 +1,8 @@
=use askama::Template;
=use chrono::DateTime;
=use chrono::Utc;
-use mailparse::parse_mail;
=use mailparse::MailHeaderMap;
+use mailparse::ParsedMail;
=use pandoc;
=use pandoc::Pandoc;
=use slug::slugify;
@@ -15,11 +15,8 @@ pub struct Note {
=
=impl Note {
=    // TODO: Return a result?
-    pub fn from_email_message(message: &imap::types::Fetch) -> Self {
-        let body = message.body().unwrap();
-        let parsed = parse_mail(body).unwrap();
-
-        let date = parsed
+    pub fn from_email_message(message: &ParsedMail) -> Self {
+        let date = message
=            .headers
=            .get_first_value("Date")
=            .expect("No date in the message header");
@@ -27,14 +24,14 @@ impl Note {
=        let timestamp = chrono::DateTime::parse_from_rfc2822(&date)
=            .expect("Failed to parse the date {date} (expected RFC2822 format)");
=
-        let text = parsed
+        let text = message
=            .get_body()
=            .expect("Failed to extract body from the message");
=
=        let content = content_to_markdown(text);
=
=        Self {
-            title: parsed.headers.get_first_value("Subject").unwrap(),
+            title: message.headers.get_first_value("Subject").unwrap(),
=            timestamp: timestamp.into(),
=            content,
=        }

Move markdown conversion to the ToMarkdown trait

On by Tad Lispy

It's implemented for Note and ParsedMail types.

index d0627ba..b777180 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,7 @@
=mod note;
+mod to_markdown;
=
+use crate::to_markdown::ToMarkdown;
=use env_logger;
=use glob::glob;
=use imap;
index e5eb2c7..3b23dab 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -1,3 +1,4 @@
+use crate::to_markdown::ToMarkdown;
=use askama::Template;
=use chrono::DateTime;
=use chrono::Utc;
@@ -24,11 +25,7 @@ impl Note {
=        let timestamp = chrono::DateTime::parse_from_rfc2822(&date)
=            .expect("Failed to parse the date {date} (expected RFC2822 format)");
=
-        let text = message
-            .get_body()
-            .expect("Failed to extract body from the message");
-
-        let content = content_to_markdown(text);
+        let content = message.to_markdown();
=
=        Self {
=            title: message.headers.get_first_value("Subject").unwrap(),
@@ -37,19 +34,6 @@ impl Note {
=        }
=    }
=
-    pub fn to_markdown(&self) -> String {
-        let markdown = MarkdownNoteTemplate {
-            title: self.title.clone(),
-            date: self.timestamp,
-            identifier: self.identifier(),
-            content: self.content.clone(),
-            // TODO: Support tags
-        };
-        markdown
-            .render()
-            .expect("Failed to render the note as markdown")
-    }
-
=    pub fn identifier(&self) -> String {
=        self.timestamp.format("%Y%m%dT%H%M%S").to_string()
=    }
@@ -74,20 +58,40 @@ struct MarkdownNoteTemplate {
=    content: String,
=}
=
-fn content_to_markdown(text: String) -> String {
-    let mut pandoc = Pandoc::new();
-    pandoc.set_input(pandoc::InputKind::Pipe(text));
-    pandoc.set_output(pandoc::OutputKind::Pipe);
-    // TODO: Support other input formats, depending on the Content-Type header
-    pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
-    pandoc.set_output_format(pandoc::OutputFormat::MarkdownGithub, Vec::new());
-
-    // This is a bit awkward. Do I have to do it like that?
-    if let pandoc::PandocOutput::ToBuffer(markdown) =
-        pandoc.execute().expect("Conversion of content failed")
-    {
+impl ToMarkdown for Note {
+    fn to_markdown(&self) -> String {
+        let markdown = MarkdownNoteTemplate {
+            title: self.title.clone(),
+            date: self.timestamp,
+            identifier: self.identifier(),
+            content: self.content.clone(),
+            // TODO: Support tags
+        };
=        markdown
-    } else {
-        panic!("Unexpected output kind from pandoc")
+            .render()
+            .expect("Failed to render the note as markdown")
+    }
+}
+
+impl ToMarkdown for ParsedMail<'_> {
+    fn to_markdown(&self) -> String {
+        let text = self
+            .get_body()
+            .expect("Failed to extract body from the message");
+        let mut pandoc = Pandoc::new();
+        pandoc.set_input(pandoc::InputKind::Pipe(text));
+        pandoc.set_output(pandoc::OutputKind::Pipe);
+        // TODO: Support other input formats, depending on the Content-Type header
+        pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
+        pandoc.set_output_format(pandoc::OutputFormat::MarkdownGithub, Vec::new());
+
+        // This is a bit awkward. Do I have to do it like that?
+        if let pandoc::PandocOutput::ToBuffer(markdown) =
+            pandoc.execute().expect("Conversion of content failed")
+        {
+            markdown
+        } else {
+            panic!("Unexpected output kind from pandoc")
+        }
=    }
=}
new file mode 100644
index 0000000..baee0f4
--- /dev/null
+++ b/src/to_markdown.rs
@@ -0,0 +1,3 @@
+pub trait ToMarkdown {
+    fn to_markdown(&self) -> String;
+}

Separate imap_import module

On by Tad Lispy

In preparation to implement export feature.

new file mode 100644
index 0000000..f7ed51b
--- /dev/null
+++ b/src/imap_import.rs
@@ -0,0 +1,160 @@
+use crate::note::Note;
+use crate::to_markdown::ToMarkdown;
+use glob::glob;
+use imap;
+use log;
+use mailparse::parse_mail;
+use native_tls;
+use native_tls::TlsStream;
+use std::env;
+use std::fs;
+use std::net::TcpStream;
+use std::path::Path;
+
+pub fn import(config: &Config) {
+    log::info!("Starting import");
+
+    let mut session = connect(&config);
+    log::debug!("Client connected and authenticated");
+
+    let mailboxes = session
+        .list(Some(""), Some("*"))
+        .expect("Failed to list mailboxes");
+    log::debug!("There are {} mailboxes", mailboxes.len());
+    for name in &mailboxes {
+        log::debug!("- {}", name.name());
+    }
+
+    session.select(&config.mailbox).unwrap();
+    log::debug!("Inbox selected: {}", &config.mailbox);
+
+    let messages = session.fetch("1:*", "RFC822").unwrap();
+    log::debug!("{} messages fetched", messages.len());
+
+    let export_path = Path::new(&config.export_path);
+    for message in &messages {
+        let body = message.body().unwrap();
+        let parsed = parse_mail(body).unwrap();
+        let note = Note::from_email_message(&parsed);
+
+        let pattern = format!(
+            "{export_path}/{identifier}--*",
+            export_path = export_path.display(),
+            identifier = note.identifier()
+        );
+
+        if let Some(existing) = glob(&pattern)
+            .expect("glob pattern should work just fine")
+            .next()
+        {
+            let existing =
+                existing.expect("it should be possible to extract path of the preexisting note");
+            log::info!("Skipping import of '{title}'. There already exists note with the same identifier: {existing}",
+                  title = &note.title,
+                  existing = existing.display()
+            );
+        } else {
+            let path = export_path.join(note.filename());
+            log::info!(
+                "Importing {path} {title}",
+                path = path.display(),
+                title = &note.title
+            );
+            fs::write(path, note.to_markdown()).expect("Failed to write the file");
+        }
+    }
+
+    if log::log_enabled!(log::Level::Debug) {
+        log::debug!("Debug logging for IMAP session enabled");
+        session.debug = true;
+    }
+
+    disconnect(session).unwrap();
+
+    log::info!("Done.");
+}
+
+#[derive(Debug)]
+pub struct Config {
+    host: String,
+    port: u16,
+    username: String,
+    password: String,
+    mailbox: String,
+    cart_path: Option<String>,
+    export_path: String,
+}
+
+impl Config {
+    pub fn from_env() -> Self {
+        Self {
+            host: env::var("imap_host")
+                .expect("There should be an environment variable named 'imap_host'"),
+            port: env::var("imap_port")
+                .expect("There should be an environment variable named 'imap_port'")
+                .parse::<u16>()
+                .expect("The 'imap_port' environment variable must be a positive number"),
+            username: env::var("imap_username")
+                .expect("There should be an environment variable named 'imap_username'"),
+            password: env::var("imap_password")
+                .expect("There should be an environment variable named 'imap_password'"),
+            mailbox: env::var("imap_mailbox")
+                .expect("There should be an environment variable named 'imap_mailbox'"),
+            export_path: env::var("imap_export_path")
+                .expect("There should be an environment variable named 'imap_export_path'"),
+            cart_path: env::var("imap_cert_path")
+                .map(Option::Some)
+                .or_else(|error| {
+                    if error == env::VarError::NotPresent {
+                        Ok(None)
+                    } else {
+                        Err(error)
+                    }
+                })
+                .expect("The value of 'imap_cert_path' environment variable is invalid"),
+        }
+    }
+}
+
+// TODO: Return a result. How to combine different types of errors (io, tls, imap)?
+fn connect(config: &Config) -> imap::Session<TlsStream<TcpStream>> {
+    // Setup TLS with a certificate from Proton Bridge
+    // TODO: Automate certificate export from Proton Mail Bridge, or at least document it.
+    // SEE: https://github.com/ProtonMail/proton-bridge/issues/315
+    let mut tls = native_tls::TlsConnector::builder();
+    if let Some(cert_path) = &config.cart_path {
+        log::debug!("Loading root certificate from {}", &cert_path);
+        let pem = &fs::read(cert_path).unwrap();
+        let cert = native_tls::Certificate::from_pem(pem).unwrap();
+        tls.add_root_certificate(cert);
+    }
+    let tls = tls.build().unwrap();
+
+    let client =
+        imap::connect_starttls((config.host.clone(), config.port), &config.host, &tls).unwrap();
+    log::debug!("Client connected");
+
+    client.login(&config.username, &config.password).unwrap()
+}
+
+fn disconnect<T>(mut session: imap::Session<T>) -> Result<(), imap::Error>
+where
+    T: std::io::Write + std::io::Read,
+{
+    // TODO: Report. See https://github.com/jonhoo/rust-imap/issues/210
+    let result = session.logout();
+    match &result {
+        Ok(_) => result,
+        Err(err) => match &err {
+            imap::Error::Parse(imap::error::ParseError::Invalid(bytes)) => {
+                if bytes.clone() == "* BYE\r\n".as_bytes() {
+                    log::debug!("The server said BYE and the client freaked out, but it's fine");
+                    Ok(())
+                } else {
+                    result
+                }
+            }
+            _ => result,
+        },
+    }
+}
index b777180..8075793 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,172 +1,21 @@
+mod imap_import;
=mod note;
=mod to_markdown;
=
-use crate::to_markdown::ToMarkdown;
=use env_logger;
-use glob::glob;
-use imap;
-use log::{debug, info};
-use mailparse::parse_mail;
-use native_tls;
-use native_tls::TlsStream;
-use note::Note;
-use std::env;
-use std::fs;
-use std::net::TcpStream;
-use std::path::Path;
=
=fn main() {
=    env_logger::init();
=
-    info!("Starting import");
+    import();
+}
=
-    let config = IMAPConfig::from_env();
+fn import() {
+    let config = imap_import::Config::from_env();
=
=    // NOTE: Avoid leaking secrets in logs
=    #[cfg(debug_assertions)]
-    debug!("Configuration loaded {config:?}");
-
-    let mut session = imap_connect(&config);
-    debug!("Client connected and authenticated");
-
-    let mailboxes = session
-        .list(Some(""), Some("*"))
-        .expect("Failed to list mailboxes");
-    debug!("There are {} mailboxes", mailboxes.len());
-    for name in &mailboxes {
-        debug!("- {}", name.name());
-    }
-
-    session.select(&config.mailbox).unwrap();
-    debug!("Inbox selected: {}", &config.mailbox);
-
-    let messages = session.fetch("1:*", "RFC822").unwrap();
-    debug!("{} messages fetched", messages.len());
-
-    let export_path = Path::new(&config.export_path);
-    for message in &messages {
-        let body = message.body().unwrap();
-        let parsed = parse_mail(body).unwrap();
-        let note = Note::from_email_message(&parsed);
-
-        let pattern = format!(
-            "{export_path}/{identifier}--*",
-            export_path = export_path.display(),
-            identifier = note.identifier()
-        );
-
-        if let Some(existing) = glob(&pattern)
-            .expect("glob pattern should work just fine")
-            .next()
-        {
-            let existing =
-                existing.expect("it should be possible to extract path of the preexisting note");
-            info!("Skipping import of '{title}'. There already exists note with the same identifier: {existing}",
-                  title = &note.title,
-                  existing = existing.display()
-            );
-        } else {
-            let path = export_path.join(note.filename());
-            info!(
-                "Importing {path} {title}",
-                path = path.display(),
-                title = &note.title
-            );
-            fs::write(path, note.to_markdown()).expect("Failed to write the file");
-        }
-    }
-
-    if log::log_enabled!(log::Level::Debug) {
-        debug!("Debug logging for IMAP session enabled");
-        session.debug = true;
-    }
-
-    imap_disconnect(session).unwrap();
-
-    info!("Done.");
-}
-
-// TODO: Return a result. How to combine different types of errors (io, tls, imap)?
-fn imap_connect(config: &IMAPConfig) -> imap::Session<TlsStream<TcpStream>> {
-    // Setup TLS with a certificate from Proton Bridge
-    // TODO: Automate certificate export from Proton Mail Bridge, or at least document it.
-    // SEE: https://github.com/ProtonMail/proton-bridge/issues/315
-    let mut tls = native_tls::TlsConnector::builder();
-    if let Some(cert_path) = &config.cart_path {
-        debug!("Loading root certificate from {}", &cert_path);
-        let pem = &fs::read(cert_path).unwrap();
-        let cert = native_tls::Certificate::from_pem(pem).unwrap();
-        tls.add_root_certificate(cert);
-    }
-    let tls = tls.build().unwrap();
-
-    let client =
-        imap::connect_starttls((config.host.clone(), config.port), &config.host, &tls).unwrap();
-    debug!("Client connected");
-
-    client.login(&config.username, &config.password).unwrap()
-}
-
-fn imap_disconnect<T>(mut session: imap::Session<T>) -> Result<(), imap::Error>
-where
-    T: std::io::Write + std::io::Read,
-{
-    // TODO: Report. See https://github.com/jonhoo/rust-imap/issues/210
-    let result = session.logout();
-    match &result {
-        Ok(_) => result,
-        Err(err) => match &err {
-            imap::Error::Parse(imap::error::ParseError::Invalid(bytes)) => {
-                if bytes.clone() == "* BYE\r\n".as_bytes() {
-                    debug!("The server said BYE and the client freaked out, but it's fine");
-                    Ok(())
-                } else {
-                    result
-                }
-            }
-            _ => result,
-        },
-    }
-}
-
-#[derive(Debug)]
-pub struct IMAPConfig {
-    host: String,
-    port: u16,
-    username: String,
-    password: String,
-    mailbox: String,
-    cart_path: Option<String>,
-    export_path: String,
-}
+    log::debug!("Configuration loaded {config:?}");
=
-impl IMAPConfig {
-    fn from_env() -> Self {
-        Self {
-            host: env::var("imap_host")
-                .expect("There should be an environment variable named 'imap_host'"),
-            port: env::var("imap_port")
-                .expect("There should be an environment variable named 'imap_port'")
-                .parse::<u16>()
-                .expect("The 'imap_port' environment variable must be a positive number"),
-            username: env::var("imap_username")
-                .expect("There should be an environment variable named 'imap_username'"),
-            password: env::var("imap_password")
-                .expect("There should be an environment variable named 'imap_password'"),
-            mailbox: env::var("imap_mailbox")
-                .expect("There should be an environment variable named 'imap_mailbox'"),
-            export_path: env::var("imap_export_path")
-                .expect("There should be an environment variable named 'imap_export_path'"),
-            cart_path: env::var("imap_cert_path")
-                .map(Option::Some)
-                .or_else(|error| {
-                    if error == env::VarError::NotPresent {
-                        Ok(None)
-                    } else {
-                        Err(error)
-                    }
-                })
-                .expect("The value of 'imap_cert_path' environment variable is invalid"),
-        }
-    }
+    imap_import::import(&config)
=}

Setup a CLI with import and export subcommands

On by Tad Lispy

I use clap crate here. The export subcommand is not yet implemented.

index 9b83e24..cdf6228 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,54 @@ dependencies = [
= "libc",
=]
=
+[[package]]
+name = "anstream"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
=[[package]]
=name = "arrayvec"
=version = "0.5.2"
@@ -163,6 +211,52 @@ dependencies = [
= "windows-targets 0.48.5",
=]
=
+[[package]]
+name = "clap"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
=[[package]]
=name = "core-foundation"
=version = "0.9.4"
@@ -191,6 +285,7 @@ version = "0.1.0"
=dependencies = [
= "askama",
= "chrono",
+ "clap",
= "env_logger",
= "glob",
= "imap",
@@ -278,6 +373,12 @@ version = "0.12.3"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
=
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
=[[package]]
=name = "hermit-abi"
=version = "0.3.3"
@@ -755,6 +856,12 @@ version = "1.1.0"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
=
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
=[[package]]
=name = "syn"
=version = "2.0.48"
@@ -809,6 +916,12 @@ version = "0.2.10"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b"
=
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
=[[package]]
=name = "vcpkg"
=version = "0.2.15"
index 35b6701..da2a1a1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,3 +16,4 @@ pandoc = "0.8.11"
=askama = { version = "0.12.1", features = [ "serde-yaml" ] }
=slug = "0.1.5"
=glob = "0.3.1"
+clap = { version = "4.4.18", features = ["derive"] }
index 8075793..99ce1e8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,12 +2,31 @@ mod imap_import;
=mod note;
=mod to_markdown;
=
+use clap::{self, Parser, Subcommand};
=use env_logger;
=
+#[derive(clap::Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    #[command(subcommand)]
+    command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+    Import,
+    Export,
+}
+
=fn main() {
=    env_logger::init();
=
-    import();
+    let cli = Cli::parse();
+
+    match cli.command {
+        Command::Import => import(),
+        Command::Export => todo!(),
+    }
=}
=
=fn import() {

Implement the list command

On by Tad Lispy

It prints date and title of a note. To make it happen, I needed to start working on reading notes from files and parsing their frontmatter. This will be required to implement export and that's why I started with the list command, because it's significantly easier, and almost all the logic it contains will be required for exporting.

index cdf6228..352ffe3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -92,7 +92,7 @@ dependencies = [
= "num-traits",
= "percent-encoding",
= "serde",
- "serde_yaml",
+ "serde_yaml 0.9.21",
=]
=
=[[package]]
@@ -293,7 +293,9 @@ dependencies = [
= "mailparse",
= "native-tls",
= "pandoc",
+ "serde",
= "slug",
+ "yaml-front-matter",
=]
=
=[[package]]
@@ -524,6 +526,12 @@ version = "0.2.8"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
=
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
=[[package]]
=name = "linux-raw-sys"
=version = "0.4.12"
@@ -827,6 +835,18 @@ dependencies = [
= "syn",
=]
=
+[[package]]
+name = "serde_yaml"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b"
+dependencies = [
+ "indexmap",
+ "ryu",
+ "serde",
+ "yaml-rust",
+]
+
=[[package]]
=name = "serde_yaml"
=version = "0.9.21"
@@ -1150,3 +1170,22 @@ name = "windows_x86_64_msvc"
=version = "0.52.0"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
+
+[[package]]
+name = "yaml-front-matter"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a94fb32d2b438e3fddf901fbfe9eb87b34d63853ca6c6da5d2ab7e27031e0bae"
+dependencies = [
+ "serde",
+ "serde_yaml 0.8.26",
+]
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
index da2a1a1..dbc68e0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,3 +17,5 @@ askama = { version = "0.12.1", features = [ "serde-yaml" ] }
=slug = "0.1.5"
=glob = "0.3.1"
=clap = { version = "4.4.18", features = ["derive"] }
+yaml-front-matter = "0.1.0"
+serde = "1.0.195"
index f7ed51b..8eeb9a4 100644
--- a/src/imap_import.rs
+++ b/src/imap_import.rs
@@ -50,7 +50,7 @@ pub fn import(config: &Config) {
=            let existing =
=                existing.expect("it should be possible to extract path of the preexisting note");
=            log::info!("Skipping import of '{title}'. There already exists note with the same identifier: {existing}",
-                  title = &note.title,
+                  title = &note.document.metadata.title,
=                  existing = existing.display()
=            );
=        } else {
@@ -58,7 +58,7 @@ pub fn import(config: &Config) {
=            log::info!(
=                "Importing {path} {title}",
=                path = path.display(),
-                title = &note.title
+                title = &note.document.metadata.title
=            );
=            fs::write(path, note.to_markdown()).expect("Failed to write the file");
=        }
index 99ce1e8..0c872dc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,9 @@ mod to_markdown;
=
=use clap::{self, Parser, Subcommand};
=use env_logger;
+use glob::glob;
+use note::Note;
+use std::path::PathBuf;
=
=#[derive(clap::Parser)]
=#[command(author, version, about, long_about = None)]
@@ -15,6 +18,7 @@ struct Cli {
=#[derive(Subcommand)]
=enum Command {
=    Import,
+    List { directory: PathBuf },
=    Export,
=}
=
@@ -25,6 +29,7 @@ fn main() {
=
=    match cli.command {
=        Command::Import => import(),
+        Command::List { directory } => list(directory),
=        Command::Export => todo!(),
=    }
=}
@@ -38,3 +43,20 @@ fn import() {
=
=    imap_import::import(&config)
=}
+
+fn list(directory_path: PathBuf) {
+    log::info!("Listing notes");
+
+    let pattern = format!("{path}/*--*.md", path = directory_path.display());
+    let note_paths = glob(&pattern).expect("glob pattern should work just fine");
+
+    for note_path in note_paths {
+        let note_path = note_path.expect("The matching path should be correct");
+        let note = Note::read(note_path);
+        println!(
+            "{date}\t{title}",
+            date = &note.document.metadata.date,
+            title = &note.document.metadata.title
+        )
+    }
+}
index 3b23dab..b88123a 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -6,12 +6,25 @@ use mailparse::MailHeaderMap;
=use mailparse::ParsedMail;
=use pandoc;
=use pandoc::Pandoc;
+use serde::Deserialize;
=use slug::slugify;
+use std::fs;
+use std::path::PathBuf;
+use yaml_front_matter::{Document, YamlFrontMatter};
=
=pub struct Note {
+    pub document: Document<Metadata>,
+    // TODO: backlinks
+    // TODO: path
+}
+
+#[derive(Deserialize)]
+pub struct Metadata {
=    pub title: String,
-    pub timestamp: DateTime<Utc>,
-    pub content: String,
+    pub date: DateTime<Utc>,
+    pub identifier: String,
+    #[serde(default)]
+    pub tags: Vec<String>,
=}
=
=impl Note {
@@ -22,20 +35,26 @@ impl Note {
=            .get_first_value("Date")
=            .expect("No date in the message header");
=
-        let timestamp = chrono::DateTime::parse_from_rfc2822(&date)
-            .expect("Failed to parse the date {date} (expected RFC2822 format)");
+        let date = chrono::DateTime::parse_from_rfc2822(&date)
+            .expect("Failed to parse the date {date} (expected RFC2822 format)")
+            .with_timezone(&Utc);
=
=        let content = message.to_markdown();
=
-        Self {
+        let metadata = Metadata {
=            title: message.headers.get_first_value("Subject").unwrap(),
-            timestamp: timestamp.into(),
-            content,
-        }
+            date,
+            tags: vec![],
+            identifier: identifier_from_date(&date),
+        };
+
+        let document = Document { metadata, content };
+
+        Self { document }
=    }
=
=    pub fn identifier(&self) -> String {
-        self.timestamp.format("%Y%m%dT%H%M%S").to_string()
+        identifier_from_date(&self.document.metadata.date)
=    }
=
=    pub fn filename(&self) -> String {
@@ -44,9 +63,20 @@ impl Note {
=        format!(
=            "{identifier}--{slug}.md",
=            identifier = self.identifier(),
-            slug = slugify(&self.title),
+            slug = slugify(&self.document.metadata.title),
=        )
=    }
+
+    pub fn read(path: PathBuf) -> Self {
+        let markdown = fs::read_to_string(path).expect("To read the file contents");
+        let document = YamlFrontMatter::parse::<Metadata>(&markdown)
+            .expect("The file to be well formatted markdown");
+        Self { document }
+    }
+}
+
+fn identifier_from_date(date: &DateTime<Utc>) -> String {
+    date.format("%Y%m%dT%H%M%S").to_string()
=}
=
=#[derive(Template)]
@@ -61,10 +91,10 @@ struct MarkdownNoteTemplate {
=impl ToMarkdown for Note {
=    fn to_markdown(&self) -> String {
=        let markdown = MarkdownNoteTemplate {
-            title: self.title.clone(),
-            date: self.timestamp,
+            title: self.document.metadata.title.clone(),
+            date: self.document.metadata.date,
=            identifier: self.identifier(),
-            content: self.content.clone(),
+            content: self.document.content.clone(),
=            // TODO: Support tags
=        };
=        markdown

Qualify clap imports

On by Tad Lispy

For clarity

index 0c872dc..5b74bc7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,8 @@ mod imap_import;
=mod note;
=mod to_markdown;
=
-use clap::{self, Parser, Subcommand};
+use clap;
+use clap::Parser;
=use env_logger;
=use glob::glob;
=use note::Note;
@@ -15,7 +16,7 @@ struct Cli {
=    command: Command,
=}
=
-#[derive(Subcommand)]
+#[derive(clap::Subcommand)]
=enum Command {
=    Import,
=    List { directory: PathBuf },

Creates a Notes collection

On by Tad Lispy

It has a load function that returns an instance of Notes by reading markdown files from a directory and a list method that returns an iterator of Note.

I had difficulty returning the iterator. The compiler was complaining about lifetimes. Fortunately I found a solution in this excelent article: https://blog.katona.me/2019/12/29/Rust-Lifetimes-and-Iterators/

The reason for creating this collection is that I need to find backlinks and store them in Note objects, in order to output them when exporting. So the program needs to keep all the notes in memory for a while.

index 5b74bc7..d42c5cf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,12 +1,12 @@
=mod imap_import;
=mod note;
+mod notes;
=mod to_markdown;
=
+use crate::notes::Notes;
=use clap;
=use clap::Parser;
=use env_logger;
-use glob::glob;
-use note::Note;
=use std::path::PathBuf;
=
=#[derive(clap::Parser)]
@@ -48,16 +48,11 @@ fn import() {
=fn list(directory_path: PathBuf) {
=    log::info!("Listing notes");
=
-    let pattern = format!("{path}/*--*.md", path = directory_path.display());
-    let note_paths = glob(&pattern).expect("glob pattern should work just fine");
-
-    for note_path in note_paths {
-        let note_path = note_path.expect("The matching path should be correct");
-        let note = Note::read(note_path);
+    for note in Notes::load(directory_path).list() {
=        println!(
=            "{date}\t{title}",
-            date = &note.document.metadata.date,
-            title = &note.document.metadata.title
+            date = note.document.metadata.date,
+            title = note.document.metadata.title
=        )
=    }
=}
new file mode 100644
index 0000000..6ab6349
--- /dev/null
+++ b/src/notes.rs
@@ -0,0 +1,48 @@
+use crate::note::Note;
+use glob::glob;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+#[derive(Default)]
+pub struct Notes {
+    pub collection: HashMap<String, Note>,
+}
+
+impl Notes {
+    pub fn new(collection: HashMap<String, Note>) -> Self {
+        Self { collection }
+    }
+
+    pub fn load(directory_path: PathBuf) -> Self {
+        let pattern = format!("{path}/*--*.md", path = directory_path.display());
+        let note_paths = glob(&pattern).expect("glob pattern should work just fine");
+        let mut notes = Notes::default();
+
+        for note_path in note_paths {
+            let note_path = note_path.expect("The matching path should be correct");
+            let note = Note::read(note_path);
+            notes.insert(note);
+        }
+
+        notes
+    }
+
+    pub fn insert(&mut self, note: Note) -> &Self {
+        self.collection.insert(note.identifier(), note);
+        self
+    }
+
+    pub fn list(&self) -> impl Iterator<Item = &Note> + '_ {
+        self.collection.values()
+    }
+}
+
+impl From<Vec<Note>> for Notes {
+    fn from(input: Vec<Note>) -> Self {
+        let mut collection = HashMap::with_capacity(input.len());
+        for note in input {
+            collection.insert(note.identifier(), note);
+        }
+        Self::new(collection)
+    }
+}

Let the import take path as a CLI parameter

On by Tad Lispy

It's more flexible than reading it from an environment variable.

index 8eeb9a4..51e9fd1 100644
--- a/src/imap_import.rs
+++ b/src/imap_import.rs
@@ -9,11 +9,13 @@ use native_tls::TlsStream;
=use std::env;
=use std::fs;
=use std::net::TcpStream;
-use std::path::Path;
+use std::path::PathBuf;
=
-pub fn import(config: &Config) {
+pub fn import(config: &Config, directory: impl Into<PathBuf>) {
=    log::info!("Starting import");
=
+    let import_path = directory.into();
+
=    let mut session = connect(&config);
=    log::debug!("Client connected and authenticated");
=
@@ -31,7 +33,6 @@ pub fn import(config: &Config) {
=    let messages = session.fetch("1:*", "RFC822").unwrap();
=    log::debug!("{} messages fetched", messages.len());
=
-    let export_path = Path::new(&config.export_path);
=    for message in &messages {
=        let body = message.body().unwrap();
=        let parsed = parse_mail(body).unwrap();
@@ -39,7 +40,7 @@ pub fn import(config: &Config) {
=
=        let pattern = format!(
=            "{export_path}/{identifier}--*",
-            export_path = export_path.display(),
+            export_path = import_path.display(),
=            identifier = note.identifier()
=        );
=
@@ -54,7 +55,7 @@ pub fn import(config: &Config) {
=                  existing = existing.display()
=            );
=        } else {
-            let path = export_path.join(note.filename());
+            let path = import_path.join(note.filename());
=            log::info!(
=                "Importing {path} {title}",
=                path = path.display(),
@@ -82,7 +83,6 @@ pub struct Config {
=    password: String,
=    mailbox: String,
=    cart_path: Option<String>,
-    export_path: String,
=}
=
=impl Config {
@@ -100,8 +100,6 @@ impl Config {
=                .expect("There should be an environment variable named 'imap_password'"),
=            mailbox: env::var("imap_mailbox")
=                .expect("There should be an environment variable named 'imap_mailbox'"),
-            export_path: env::var("imap_export_path")
-                .expect("There should be an environment variable named 'imap_export_path'"),
=            cart_path: env::var("imap_cert_path")
=                .map(Option::Some)
=                .or_else(|error| {
index d42c5cf..127edad 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,7 +18,7 @@ struct Cli {
=
=#[derive(clap::Subcommand)]
=enum Command {
-    Import,
+    Import { directory: PathBuf },
=    List { directory: PathBuf },
=    Export,
=}
@@ -29,20 +29,20 @@ fn main() {
=    let cli = Cli::parse();
=
=    match cli.command {
-        Command::Import => import(),
+        Command::Import { directory } => import(directory),
=        Command::List { directory } => list(directory),
=        Command::Export => todo!(),
=    }
=}
=
-fn import() {
+fn import(directory: PathBuf) {
=    let config = imap_import::Config::from_env();
=
=    // NOTE: Avoid leaking secrets in logs
=    #[cfg(debug_assertions)]
=    log::debug!("Configuration loaded {config:?}");
=
-    imap_import::import(&config)
+    imap_import::import(&config, directory)
=}
=
=fn list(directory_path: PathBuf) {

On by Tad Lispy

This is a step toward finding backlinks to each note. For now the export command just prints all the outgoing links for each note.

index 352ffe3..90dd413 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -293,6 +293,7 @@ dependencies = [
= "mailparse",
= "native-tls",
= "pandoc",
+ "pulldown-cmark",
= "serde",
= "slug",
= "yaml-front-matter",
@@ -363,6 +364,15 @@ version = "0.1.1"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
=
+[[package]]
+name = "getopts"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
+dependencies = [
+ "unicode-width",
+]
+
=[[package]]
=name = "glob"
=version = "0.3.1"
@@ -711,6 +721,18 @@ dependencies = [
= "unicode-ident",
=]
=
+[[package]]
+name = "pulldown-cmark"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a1a2f1f0a7ecff9c31abbe177637be0e97a0aef46cf8738ece09327985d998"
+dependencies = [
+ "bitflags 1.3.2",
+ "getopts",
+ "memchr",
+ "unicase",
+]
+
=[[package]]
=name = "quote"
=version = "1.0.35"
@@ -930,6 +952,12 @@ version = "1.0.12"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
=
+[[package]]
+name = "unicode-width"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
+
=[[package]]
=name = "unsafe-libyaml"
=version = "0.2.10"
index dbc68e0..4c61941 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,3 +19,4 @@ glob = "0.3.1"
=clap = { version = "4.4.18", features = ["derive"] }
=yaml-front-matter = "0.1.0"
=serde = "1.0.195"
+pulldown-cmark = "0.9.3"
index 127edad..5162e98 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,6 +7,7 @@ use crate::notes::Notes;
=use clap;
=use clap::Parser;
=use env_logger;
+use std::fs::create_dir_all;
=use std::path::PathBuf;
=
=#[derive(clap::Parser)]
@@ -20,7 +21,7 @@ struct Cli {
=enum Command {
=    Import { directory: PathBuf },
=    List { directory: PathBuf },
-    Export,
+    Export { from: PathBuf, into: PathBuf },
=}
=
=fn main() {
@@ -31,7 +32,7 @@ fn main() {
=    match cli.command {
=        Command::Import { directory } => import(directory),
=        Command::List { directory } => list(directory),
-        Command::Export => todo!(),
+        Command::Export { from, into } => export(from, into),
=    }
=}
=
@@ -56,3 +57,20 @@ fn list(directory_path: PathBuf) {
=        )
=    }
=}
+
+fn export(from_directory: PathBuf, into_directory: PathBuf) {
+    create_dir_all(into_directory).expect("ensuring the export directory exists shouldn't fail");
+    let notes = Notes::load(from_directory); // TODO: .find_backlinks();
+
+    for note in notes.list() {
+        println!(
+            "{date}\t{title}",
+            date = note.document.metadata.date,
+            title = note.document.metadata.title
+        );
+
+        for link in note.links() {
+            println!("-> {href}", href = link)
+        }
+    }
+}
index b88123a..5c1d9d8 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -73,6 +73,17 @@ impl Note {
=            .expect("The file to be well formatted markdown");
=        Self { document }
=    }
+
+    pub fn links(&self) -> impl Iterator<Item = String> + '_ {
+        let markdown = &self.document.content;
+        let parser = pulldown_cmark::Parser::new(markdown);
+        parser.filter_map(|event| match event {
+            pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link(_type, href, _title)) => {
+                Some(href.into_string())
+            }
+            _ => None,
+        })
+    }
=}
=
=fn identifier_from_date(date: &DateTime<Utc>) -> String {

Use identifier instead of date for Note::identifier

On by Tad Lispy

Why was it ever like that?

index 5c1d9d8..0adc054 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -54,7 +54,7 @@ impl Note {
=    }
=
=    pub fn identifier(&self) -> String {
-        identifier_from_date(&self.document.metadata.date)
+        self.document.metadata.identifier.clone()
=    }
=
=    pub fn filename(&self) -> String {

On by Tad Lispy

Getting closer to actual export. At first I thought that backlinks should be reified in a Note struct, but then I realised it's much cleaner to have a Notes::backlinks method that returns a HashMap from target not to a set of sources (as identifiers).

index 90dd413..f37e01a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -296,6 +296,7 @@ dependencies = [
= "pulldown-cmark",
= "serde",
= "slug",
+ "url",
= "yaml-front-matter",
=]
=
@@ -364,6 +365,15 @@ version = "0.1.1"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
=
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
=[[package]]
=name = "getopts"
=version = "0.2.21"
@@ -435,6 +445,16 @@ dependencies = [
= "cc",
=]
=
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
=[[package]]
=name = "imap"
=version = "2.4.1"
@@ -937,6 +957,21 @@ dependencies = [
= "winapi-util",
=]
=
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
=[[package]]
=name = "unicase"
=version = "2.7.0"
@@ -946,12 +981,27 @@ dependencies = [
= "version_check",
=]
=
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
=[[package]]
=name = "unicode-ident"
=version = "1.0.12"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
=
+[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
=[[package]]
=name = "unicode-width"
=version = "0.1.11"
@@ -964,6 +1014,17 @@ version = "0.2.10"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b"
=
+[[package]]
+name = "url"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
=[[package]]
=name = "utf8parse"
=version = "0.2.1"
index 4c61941..6b962a4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,3 +20,4 @@ clap = { version = "4.4.18", features = ["derive"] }
=yaml-front-matter = "0.1.0"
=serde = "1.0.195"
=pulldown-cmark = "0.9.3"
+url = "2.5.0"
index 5162e98..882cf5d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -60,7 +60,8 @@ fn list(directory_path: PathBuf) {
=
=fn export(from_directory: PathBuf, into_directory: PathBuf) {
=    create_dir_all(into_directory).expect("ensuring the export directory exists shouldn't fail");
-    let notes = Notes::load(from_directory); // TODO: .find_backlinks();
+    let notes = Notes::load(from_directory);
+    let backlinks = notes.backlinks();
=
=    for note in notes.list() {
=        println!(
@@ -72,5 +73,15 @@ fn export(from_directory: PathBuf, into_directory: PathBuf) {
=        for link in note.links() {
=            println!("-> {href}", href = link)
=        }
+
+        for source in backlinks
+            .get(&note.identifier())
+            .cloned()
+            .unwrap_or_default()
+        {
+            if let Some(source) = notes.collection.get(&source) {
+                println!("<- {source}", source = source.document.metadata.title)
+            }
+        }
=    }
=}
index 0adc054..b524ee0 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -14,7 +14,6 @@ use yaml_front_matter::{Document, YamlFrontMatter};
=
=pub struct Note {
=    pub document: Document<Metadata>,
-    // TODO: backlinks
=    // TODO: path
=}
=
index 6ab6349..e3edda8 100644
--- a/src/notes.rs
+++ b/src/notes.rs
@@ -1,7 +1,9 @@
=use crate::note::Note;
=use glob::glob;
=use std::collections::HashMap;
+use std::collections::HashSet;
=use std::path::PathBuf;
+use url::Url;
=
=#[derive(Default)]
=pub struct Notes {
@@ -35,6 +37,40 @@ impl Notes {
=    pub fn list(&self) -> impl Iterator<Item = &Note> + '_ {
=        self.collection.values()
=    }
+
+    pub fn backlinks(&self) -> Backlinks {
+        let mut backlinks = Backlinks::default();
+
+        for source in self.list() {
+            for link in source.links() {
+                log::debug!("Parsing link {}", link);
+                match Url::parse(&link) {
+                    Ok(url) => {
+                        if url.scheme() == "denote" {
+                            let target = url.path().to_string();
+                            backlinks
+                                .entry(target)
+                                .and_modify(|sources| {
+                                    sources.insert(source.identifier());
+                                })
+                                .or_insert(vec![source.identifier()].into_iter().collect());
+                        }
+                    }
+                    Err(error) => {
+                        // TODO: Gracefully support email autolinks. No need to
+                        // warn about them. Probably best to avoid emitting them
+                        // from Note::links method
+                        log::warn!(
+                            "Problem processing link {link} in note {source_id} ({title}): {error}",
+                            source_id = source.identifier(),
+                            title = source.document.metadata.title,
+                        );
+                    }
+                }
+            }
+        }
+        backlinks
+    }
=}
=
=impl From<Vec<Note>> for Notes {
@@ -46,3 +82,5 @@ impl From<Vec<Note>> for Notes {
=        Self::new(collection)
=    }
=}
+
+type Backlinks = HashMap<String, HashSet<String>>;

Implement and use getters for note metadata

On by Tad Lispy

index 51e9fd1..cc6b445 100644
--- a/src/imap_import.rs
+++ b/src/imap_import.rs
@@ -51,7 +51,7 @@ pub fn import(config: &Config, directory: impl Into<PathBuf>) {
=            let existing =
=                existing.expect("it should be possible to extract path of the preexisting note");
=            log::info!("Skipping import of '{title}'. There already exists note with the same identifier: {existing}",
-                  title = &note.document.metadata.title,
+                  title = &note.title(),
=                  existing = existing.display()
=            );
=        } else {
@@ -59,7 +59,7 @@ pub fn import(config: &Config, directory: impl Into<PathBuf>) {
=            log::info!(
=                "Importing {path} {title}",
=                path = path.display(),
-                title = &note.document.metadata.title
+                title = note.title()
=            );
=            fs::write(path, note.to_markdown()).expect("Failed to write the file");
=        }
index 882cf5d..adfa792 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -50,11 +50,7 @@ fn list(directory_path: PathBuf) {
=    log::info!("Listing notes");
=
=    for note in Notes::load(directory_path).list() {
-        println!(
-            "{date}\t{title}",
-            date = note.document.metadata.date,
-            title = note.document.metadata.title
-        )
+        println!("{date}\t{title}", date = note.date(), title = note.title())
=    }
=}
=
index b524ee0..94b0d6e 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -56,16 +56,32 @@ impl Note {
=        self.document.metadata.identifier.clone()
=    }
=
-    pub fn filename(&self) -> String {
+    pub fn title(&self) -> String {
+        self.document.metadata.title.clone()
+    }
+
+    pub fn date(&self) -> DateTime<Utc> {
+        self.document.metadata.date.clone()
+    }
+
+    pub fn body(&self) -> String {
+        self.document.content.clone()
+    }
+
+    pub fn slug(&self) -> String {
=        // TODO: Support tags in file name.
=        // NOTE: If tags are present, then after slug there is __ and then tags separated by _.
=        format!(
-            "{identifier}--{slug}.md",
+            "{identifier}--{slug}",
=            identifier = self.identifier(),
-            slug = slugify(&self.document.metadata.title),
+            slug = slugify(&self.title()),
=        )
=    }
=
+    pub fn filename(&self) -> String {
+        self.slug() + ".md"
+    }
+
=    pub fn read(path: PathBuf) -> Self {
=        let markdown = fs::read_to_string(path).expect("To read the file contents");
=        let document = YamlFrontMatter::parse::<Metadata>(&markdown)
@@ -101,10 +117,10 @@ struct MarkdownNoteTemplate {
=impl ToMarkdown for Note {
=    fn to_markdown(&self) -> String {
=        let markdown = MarkdownNoteTemplate {
-            title: self.document.metadata.title.clone(),
-            date: self.document.metadata.date,
+            title: self.title(),
+            date: self.date(),
=            identifier: self.identifier(),
-            content: self.document.content.clone(),
+            content: self.body(),
=            // TODO: Support tags
=        };
=        markdown
index e3edda8..fc28d5d 100644
--- a/src/notes.rs
+++ b/src/notes.rs
@@ -63,7 +63,7 @@ impl Notes {
=                        log::warn!(
=                            "Problem processing link {link} in note {source_id} ({title}): {error}",
=                            source_id = source.identifier(),
-                            title = source.document.metadata.title,
+                            title = source.title(),
=                        );
=                    }
=                }

Use template to print html output from export

On by Tad Lispy

Not writing to the files yet, but almost there. All denote links are rewritten to point to resulting note paths.

new file mode 100644
index 0000000..9d5880f
--- /dev/null
+++ b/src/html_export.rs
@@ -0,0 +1,120 @@
+use crate::{note::Note, notes::Notes};
+use askama::Template;
+use chrono::{DateTime, Utc};
+use url::Url;
+
+pub trait HtmlExport {
+    fn export_html(&self);
+}
+
+impl HtmlExport for Notes {
+    fn export_html(&self) {
+        let backlinks = self.backlinks();
+
+        for note in self.list() {
+            let backlinks: Vec<&Note> = backlinks
+                .get(&note.identifier())
+                .cloned()
+                .unwrap_or_default()
+                .into_iter()
+                .filter_map(|source| self.collection.get(&source))
+                .collect();
+
+            println!("{date}\t{title}", date = note.date(), title = note.title());
+
+            let title = note.title();
+            let date = note.date();
+            let backlinks = backlinks.into_iter().map(Backlink::from).collect();
+
+            let mut markdown_options = pulldown_cmark::Options::empty();
+            markdown_options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
+            markdown_options.insert(pulldown_cmark::Options::ENABLE_SMART_PUNCTUATION);
+            markdown_options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
+            markdown_options.insert(pulldown_cmark::Options::ENABLE_TABLES);
+            markdown_options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
+            // NOTE: To rewrite links I need access to the whole Notes collection. Probably this function needs to be a method of Notes.
+            let mut body = String::new();
+
+            let markdown = note.body();
+            let rewrite_links = |event| {
+                if let pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link(
+                    variant,
+                    href,
+                    title,
+                )) = &event
+                {
+                    match Url::parse(&href) {
+                        Ok(url) => {
+                            if url.scheme() == "denote" {
+                                let identifier = url.path().to_string();
+                                if let Some(note) = self.collection.get(&identifier) {
+                                    let href = note.slug() + ".html";
+                                    pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link(
+                                        variant.clone(),
+                                        href.into(),
+                                        title.clone(),
+                                    ))
+                                } else {
+                                    event
+                                }
+                            } else {
+                                event
+                            }
+                        }
+                        Err(error) => {
+                            // TODO: Gracefully support email autolinks. No need to
+                            // warn about them. Probably best to avoid emitting them
+                            // from Note::links method
+                            log::warn!(
+                                    "Problem processing link {href} in note {source_id} ({title}): {error}",
+                                    source_id = note.identifier(),
+                                    title = note.title(),
+                                );
+                            event
+                        }
+                    }
+                } else {
+                    event
+                }
+            };
+            let parser =
+                pulldown_cmark::Parser::new_ext(&markdown, markdown_options).map(rewrite_links);
+
+            pulldown_cmark::html::push_html(&mut body, parser);
+
+            let html = HtmlTemplate {
+                title,
+                date,
+                body,
+                backlinks,
+            }
+            .render()
+            .expect("The template should render just fine");
+
+            println!("{html}");
+        }
+    }
+}
+
+#[derive(Template)]
+#[template(path = "note.html")]
+struct HtmlTemplate {
+    title: String,
+    date: DateTime<Utc>,
+    backlinks: Vec<Backlink>,
+    body: String,
+}
+
+struct Backlink {
+    title: String,
+    href: String,
+}
+
+impl From<&Note> for Backlink {
+    fn from(note: &Note) -> Self {
+        Self {
+            title: note.title(),
+            href: note.slug() + ".html",
+        }
+    }
+}
index adfa792..881f93f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,10 @@
+mod html_export;
=mod imap_import;
=mod note;
=mod notes;
=mod to_markdown;
=
+use crate::html_export::HtmlExport;
=use crate::notes::Notes;
=use clap;
=use clap::Parser;
@@ -57,27 +59,6 @@ fn list(directory_path: PathBuf) {
=fn export(from_directory: PathBuf, into_directory: PathBuf) {
=    create_dir_all(into_directory).expect("ensuring the export directory exists shouldn't fail");
=    let notes = Notes::load(from_directory);
-    let backlinks = notes.backlinks();
=
-    for note in notes.list() {
-        println!(
-            "{date}\t{title}",
-            date = note.document.metadata.date,
-            title = note.document.metadata.title
-        );
-
-        for link in note.links() {
-            println!("-> {href}", href = link)
-        }
-
-        for source in backlinks
-            .get(&note.identifier())
-            .cloned()
-            .unwrap_or_default()
-        {
-            if let Some(source) = notes.collection.get(&source) {
-                println!("<- {source}", source = source.document.metadata.title)
-            }
-        }
-    }
+    notes.export_html()
=}
new file mode 100644
index 0000000..149b6d9
--- /dev/null
+++ b/templates/note.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html class="no-js" lang="">
+    <head>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <title>{{ title }}</title>
+        <meta name="description" content="">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+        <!-- Place favicon.ico in the root directory -->
+
+    </head>
+    <body>
+        <header>
+            <h1>{{ title }}</h1>
+            <p>{{ date }}</p>
+        </header>
+
+        <main>
+            {{ body|indent(12)|safe }}
+        </main>
+
+        <footer>
+            <p>Linking to this "{{ title }}"</p>
+            {% for backlink in backlinks %}
+                <li><a  href="{{ backlink.href }}">{{ backlink.title }}</a></li>
+            {% endfor %}
+        </footer>
+
+    </body>
+</html>

The export command will write html files

On by Tad Lispy

Also, make develop will now run import, list and export, and then start a local server to serve exported notes.

index d3fe861..eb88c66 100644
--- a/Makefile
+++ b/Makefile
@@ -30,7 +30,10 @@ install: build
=develop: ## Rebuild and run a development version of the program
=develop: log-level ?= info
=develop:
-	RUST_LOG=$(log-level) cargo run
+	RUST_LOG=$(log-level) cargo run -- import notes
+	RUST_LOG=$(log-level) cargo run -- list notes
+	RUST_LOG=$(log-level) cargo run -- export notes exported
+	miniserve exported
=.PHONY: develop
=
=clean: ## Remove all build artifacts
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/exported/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
index a00b755..938cc2a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -37,6 +37,7 @@
=          packages = with pkgs; [
=            pkgs.rust-analyzer
=            pkgs.jq
+            pkgs.miniserve
=          ];
=          project_name = project-name; # Expose as an environment variable for make
=        };
index 9d5880f..627d8a2 100644
--- a/src/html_export.rs
+++ b/src/html_export.rs
@@ -1,14 +1,15 @@
=use crate::{note::Note, notes::Notes};
=use askama::Template;
=use chrono::{DateTime, Utc};
+use std::path::PathBuf;
=use url::Url;
=
=pub trait HtmlExport {
-    fn export_html(&self);
+    fn export_html(&self, directory: &PathBuf);
=}
=
=impl HtmlExport for Notes {
-    fn export_html(&self) {
+    fn export_html(&self, directory: &PathBuf) {
=        let backlinks = self.backlinks();
=
=        for note in self.list() {
@@ -20,7 +21,11 @@ impl HtmlExport for Notes {
=                .filter_map(|source| self.collection.get(&source))
=                .collect();
=
-            println!("{date}\t{title}", date = note.date(), title = note.title());
+            log::info!(
+                "exporting {identifier} {title}",
+                identifier = note.identifier(),
+                title = note.title()
+            );
=
=            let title = note.title();
=            let date = note.date();
@@ -48,7 +53,7 @@ impl HtmlExport for Notes {
=                            if url.scheme() == "denote" {
=                                let identifier = url.path().to_string();
=                                if let Some(note) = self.collection.get(&identifier) {
-                                    let href = note.slug() + ".html";
+                                    let href = export_path(note);
=                                    pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link(
=                                        variant.clone(),
=                                        href.into(),
@@ -91,7 +96,9 @@ impl HtmlExport for Notes {
=            .render()
=            .expect("The template should render just fine");
=
-            println!("{html}");
+            let export_path = directory.join(export_path(note));
+
+            std::fs::write(export_path, html).expect("Writing to a file should work");
=        }
=    }
=}
@@ -114,7 +121,11 @@ impl From<&Note> for Backlink {
=    fn from(note: &Note) -> Self {
=        Self {
=            title: note.title(),
-            href: note.slug() + ".html",
+            href: export_path(note),
=        }
=    }
=}
+
+fn export_path(note: &Note) -> String {
+    note.slug() + ".html"
+}
index 881f93f..e42f08d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -57,8 +57,8 @@ fn list(directory_path: PathBuf) {
=}
=
=fn export(from_directory: PathBuf, into_directory: PathBuf) {
-    create_dir_all(into_directory).expect("ensuring the export directory exists shouldn't fail");
+    create_dir_all(&into_directory).expect("ensuring the export directory exists shouldn't fail");
=    let notes = Notes::load(from_directory);
=
-    notes.export_html()
+    notes.export_html(&into_directory)
=}
index 149b6d9..98b2aa8 100644
--- a/templates/note.html
+++ b/templates/note.html
@@ -22,7 +22,7 @@
=        </main>
=
=        <footer>
-            <p>Linking to this "{{ title }}"</p>
+            <p>Other notes linking to "{{ title }}"</p>
=            {% for backlink in backlinks %}
=                <li><a  href="{{ backlink.href }}">{{ backlink.title }}</a></li>
=            {% endfor %}

Implement html extraction from a multipart messages

On by Tad Lispy

Turned out, if note email message to import has attachments, the body was being set to an empty string. Now the program extracts first text/html part, or fails if none is found. It would be nice to also support text/plain, maybe as a fallback.

index 94b0d6e..e4d0023 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -131,9 +131,7 @@ impl ToMarkdown for Note {
=
=impl ToMarkdown for ParsedMail<'_> {
=    fn to_markdown(&self) -> String {
-        let text = self
-            .get_body()
-            .expect("Failed to extract body from the message");
+        let text = get_html_body(&self);
=        let mut pandoc = Pandoc::new();
=        pandoc.set_input(pandoc::InputKind::Pipe(text));
=        pandoc.set_output(pandoc::OutputKind::Pipe);
@@ -151,3 +149,16 @@ impl ToMarkdown for ParsedMail<'_> {
=        }
=    }
=}
+
+fn get_html_body(message: &ParsedMail<'_>) -> String {
+    for part in message.parts() {
+        log::debug!("Found part {}", part.ctype.mimetype);
+        if part.ctype.mimetype == "text/html" {
+            return part
+                .get_body()
+                .expect("Failed to extract body from the message");
+        }
+    }
+
+    panic!("There was no html body in the message");
+}

Specify language (en) for exported notes

On by Tad Lispy

Probably in the future it should be customizable, maybe per note. But all my notes are in English, so it's good enough for now.

index 98b2aa8..fc713e0 100644
--- a/templates/note.html
+++ b/templates/note.html
@@ -1,5 +1,5 @@
=<!doctype html>
-<html class="no-js" lang="">
+<html class="no-js" lang="en">
=    <head>
=        <meta charset="utf-8">
=        <meta http-equiv="x-ua-compatible" content="ie=edge">

On by Tad Lispy

If there are no backlinks, say so.

index fc713e0..04db96e 100644
--- a/templates/note.html
+++ b/templates/note.html
@@ -22,10 +22,14 @@
=        </main>
=
=        <footer>
+            {% if backlinks.len() == 0 %}
+            <p>No other notes link to "{{ title }}"</p>
+            {% else %}
=            <p>Other notes linking to "{{ title }}"</p>
=            {% for backlink in backlinks %}
=                <li><a  href="{{ backlink.href }}">{{ backlink.title }}</a></li>
=            {% endfor %}
+            {% endif %}
=        </footer>
=
=    </body>

Improve some error messages

On by Tad Lispy

index e4d0023..1da3908 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -83,9 +83,9 @@ impl Note {
=    }
=
=    pub fn read(path: PathBuf) -> Self {
-        let markdown = fs::read_to_string(path).expect("To read the file contents");
+        let markdown = fs::read_to_string(path).unwrap();
=        let document = YamlFrontMatter::parse::<Metadata>(&markdown)
-            .expect("The file to be well formatted markdown");
+            .expect("The file should contain a well formatted markdown + yaml");
=        Self { document }
=    }
=

The program will write index.html

On by Tad Lispy

I renamed the Backlink struct to LinkToNote, as it is reused in generating index page and in this context they are simply links.

index 627d8a2..1793964 100644
--- a/src/html_export.rs
+++ b/src/html_export.rs
@@ -29,7 +29,7 @@ impl HtmlExport for Notes {
=
=            let title = note.title();
=            let date = note.date();
-            let backlinks = backlinks.into_iter().map(Backlink::from).collect();
+            let backlinks = backlinks.into_iter().map(LinkToNote::from).collect();
=
=            let mut markdown_options = pulldown_cmark::Options::empty();
=            markdown_options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
@@ -100,6 +100,20 @@ impl HtmlExport for Notes {
=
=            std::fs::write(export_path, html).expect("Writing to a file should work");
=        }
+
+        // Write index.html
+        let mut index_notes = self.list().collect::<Vec<&Note>>();
+        index_notes.sort_by(|a, b| a.date().cmp(&b.date()));
+
+        let html = IndexHtmlTemplate {
+            date: Utc::now(),
+            notes: index_notes.into_iter().map(LinkToNote::from).collect(),
+        }
+        .render()
+        .expect("The index.html template should render just fine");
+
+        let export_path = directory.join("index.html");
+        std::fs::write(export_path, html).expect("Writing to a file should work");
=    }
=}
=
@@ -108,16 +122,23 @@ impl HtmlExport for Notes {
=struct HtmlTemplate {
=    title: String,
=    date: DateTime<Utc>,
-    backlinks: Vec<Backlink>,
+    backlinks: Vec<LinkToNote>,
=    body: String,
=}
=
-struct Backlink {
+#[derive(Template)]
+#[template(path = "index.html")]
+struct IndexHtmlTemplate {
+    date: DateTime<Utc>,
+    notes: Vec<LinkToNote>,
+}
+
+struct LinkToNote {
=    title: String,
=    href: String,
=}
=
-impl From<&Note> for Backlink {
+impl From<&Note> for LinkToNote {
=    fn from(note: &Note) -> Self {
=        Self {
=            title: note.title(),
new file mode 100644
index 0000000..04eaccb
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<html class="no-js" lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <title>Notes</title>
+        <meta name="description" content="">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+        <!-- Place favicon.ico in the root directory -->
+
+    </head>
+    <body>
+        <header>
+            <h1>Notes</h1>
+            <p>Exported on {{ date }}</p>
+        </header>
+
+        <main>
+            <ul>
+            {% for note in notes %}
+                <li><a  href="{{ note.href }}">{{ note.title }}</a></li>
+            {% endfor %}
+            </ul>
+        </main>
+    </body>
+</html>

Remove obsolete variable from Makefile

On by Tad Lispy

index eb88c66..7fd0720 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,3 @@
-name := bevy-animated-sprite-playground
-
=include Makefile.d/defaults.mk
=
=all: ## Build the program (DEFAULT)

On by Tad Lispy

Maybe this will make Android open links correctly?

index 1793964..053a8e0 100644
--- a/src/html_export.rs
+++ b/src/html_export.rs
@@ -142,7 +142,7 @@ impl From<&Note> for LinkToNote {
=    fn from(note: &Note) -> Self {
=        Self {
=            title: note.title(),
-            href: export_path(note),
+            href: format!("./{}", export_path(note)),
=        }
=    }
=}

Support import of text when HTML is not found

On by Tad Lispy

index 1da3908..cbbc7c9 100644
--- a/src/note.rs
+++ b/src/note.rs
@@ -3,6 +3,7 @@ use askama::Template;
=use chrono::DateTime;
=use chrono::Utc;
=use mailparse::MailHeaderMap;
+use mailparse::MailParseError;
=use mailparse::ParsedMail;
=use pandoc;
=use pandoc::Pandoc;
@@ -131,12 +132,23 @@ impl ToMarkdown for Note {
=
=impl ToMarkdown for ParsedMail<'_> {
=    fn to_markdown(&self) -> String {
-        let text = get_html_body(&self);
+        let body = get_message_body(&self).expect("Message should have a valid body");
=        let mut pandoc = Pandoc::new();
-        pandoc.set_input(pandoc::InputKind::Pipe(text));
+
+        match body {
+            ExtractedBody::Html(html) => {
+                pandoc.set_input(pandoc::InputKind::Pipe(html));
+                pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
+            }
+            ExtractedBody::Text(text) => {
+                pandoc.set_input(pandoc::InputKind::Pipe(text));
+                pandoc.set_input_format(
+                    pandoc::InputFormat::Markdown,
+                    vec![pandoc::MarkdownExtension::HardLineBreaks],
+                );
+            }
+        }
=        pandoc.set_output(pandoc::OutputKind::Pipe);
-        // TODO: Support other input formats, depending on the Content-Type header
-        pandoc.set_input_format(pandoc::InputFormat::Html, Vec::new());
=        pandoc.set_output_format(pandoc::OutputFormat::MarkdownGithub, Vec::new());
=
=        // This is a bit awkward. Do I have to do it like that?
@@ -150,15 +162,29 @@ impl ToMarkdown for ParsedMail<'_> {
=    }
=}
=
-fn get_html_body(message: &ParsedMail<'_>) -> String {
+fn get_message_body(message: &ParsedMail<'_>) -> Result<ExtractedBody, MailParseError> {
=    for part in message.parts() {
=        log::debug!("Found part {}", part.ctype.mimetype);
=        if part.ctype.mimetype == "text/html" {
-            return part
-                .get_body()
-                .expect("Failed to extract body from the message");
+            return part.get_body().map(ExtractedBody::Html);
+        }
+    }
+
+    log::debug!("No text/html body part. Looking for text/plain...");
+
+    for part in message.parts() {
+        log::debug!("Found part {}", part.ctype.mimetype);
+        if part.ctype.mimetype == "text/plain" {
+            return part.get_body().map(ExtractedBody::Text);
=        }
=    }
=
-    panic!("There was no html body in the message");
+    Err(MailParseError::Generic(
+        "Could not find neither text/html nor text/plain body part.",
+    ))
+}
+
+enum ExtractedBody {
+    Html(String),
+    Text(String),
=}