new: support embeds referencing headings
Previously, partial embeds (`![[note#heading]]`) would always include the entire file into the source note. Now, such embeds will only include the contents of the referenced heading (and any subheadings). Links and embeds of [arbitrary blocks] remains unsupported at this time. [arbitrary blocks]: https://publish.obsidian.md/help/How+to/Link+to+blocks
This commit is contained in:
parent
cc58ca01a5
commit
fcb4cd9dec
189
src/lib.rs
189
src/lib.rs
@ -24,7 +24,7 @@ type MarkdownTree<'a> = Vec<Event<'a>>;
|
|||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref OBSIDIAN_NOTE_LINK_RE: Regex =
|
static ref OBSIDIAN_NOTE_LINK_RE: Regex =
|
||||||
Regex::new(r"^(?P<file>[^#|]+)(#(?P<block>.+?))??(\|(?P<label>.+?))??$").unwrap();
|
Regex::new(r"^(?P<file>[^#|]+)(#(?P<section>.+?))??(\|(?P<label>.+?))??$").unwrap();
|
||||||
}
|
}
|
||||||
const PERCENTENCODE_CHARS: &AsciiSet = &CONTROLS.add(b' ').add(b'(').add(b')').add(b'%');
|
const PERCENTENCODE_CHARS: &AsciiSet = &CONTROLS.add(b' ').add(b'(').add(b')').add(b'%');
|
||||||
const NOTE_RECURSION_LIMIT: usize = 10;
|
const NOTE_RECURSION_LIMIT: usize = 10;
|
||||||
@ -114,6 +114,17 @@ struct Context {
|
|||||||
frontmatter_strategy: FrontmatterStrategy,
|
frontmatter_strategy: FrontmatterStrategy,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
/// ObsidianNoteReference represents the structure of a `[[note]]` or `![[embed]]` reference.
|
||||||
|
struct ObsidianNoteReference<'a> {
|
||||||
|
/// The file (note name or partial path) being referenced.
|
||||||
|
file: &'a str,
|
||||||
|
/// If specific, a specific section/heading being referenced.
|
||||||
|
section: Option<&'a str>,
|
||||||
|
/// If specific, the custom label/text which was specified.
|
||||||
|
label: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
/// Create a new `Context`
|
/// Create a new `Context`
|
||||||
fn new(file: PathBuf) -> Context {
|
fn new(file: PathBuf) -> Context {
|
||||||
@ -167,6 +178,26 @@ impl Context {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> ObsidianNoteReference<'a> {
|
||||||
|
fn from_str(text: &str) -> ObsidianNoteReference {
|
||||||
|
let captures = OBSIDIAN_NOTE_LINK_RE
|
||||||
|
.captures(&text)
|
||||||
|
.expect("note link regex didn't match - bad input?");
|
||||||
|
let file = captures
|
||||||
|
.name("file")
|
||||||
|
.expect("Obsidian links should always reference a file")
|
||||||
|
.as_str();
|
||||||
|
let label = captures.name("label").map(|v| v.as_str());
|
||||||
|
let section = captures.name("section").map(|v| v.as_str());
|
||||||
|
|
||||||
|
ObsidianNoteReference {
|
||||||
|
file,
|
||||||
|
label,
|
||||||
|
section,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> Exporter<'a> {
|
impl<'a> Exporter<'a> {
|
||||||
/// Create a new exporter which reads notes from `source` and exports these to
|
/// Create a new exporter which reads notes from `source` and exports these to
|
||||||
/// `destination`.
|
/// `destination`.
|
||||||
@ -387,70 +418,67 @@ impl<'a> Exporter<'a> {
|
|||||||
// - If the file being embedded is a note, it's content is included at the point of embed.
|
// - If the file being embedded is a note, it's content is included at the point of embed.
|
||||||
// - If the file is an image, an image tag is generated.
|
// - If the file is an image, an image tag is generated.
|
||||||
// - For other types of file, a regular link is created instead.
|
// - For other types of file, a regular link is created instead.
|
||||||
fn embed_file<'b>(&self, note_name: &'a str, context: &'a Context) -> Result<MarkdownTree<'a>> {
|
fn embed_file<'b>(&self, link_text: &'a str, context: &'a Context) -> Result<MarkdownTree<'a>> {
|
||||||
// TODO: If a #section is specified, reduce returned MarkdownTree to just
|
let note_ref = ObsidianNoteReference::from_str(link_text);
|
||||||
// that section.
|
|
||||||
let note_name = note_name.split('#').collect::<Vec<&str>>()[0];
|
|
||||||
|
|
||||||
let tree = match lookup_filename_in_vault(note_name, &self.vault_contents.as_ref().unwrap())
|
let path = lookup_filename_in_vault(note_ref.file, &self.vault_contents.as_ref().unwrap());
|
||||||
{
|
if path.is_none() {
|
||||||
Some(path) => {
|
// TODO: Extract into configurable function.
|
||||||
let context = Context::from_parent(context, path);
|
println!(
|
||||||
let no_ext = OsString::new();
|
"Warning: Unable to find embedded note\n\tReference: '{}'\n\tSource: '{}'\n",
|
||||||
match path.extension().unwrap_or(&no_ext).to_str() {
|
note_ref.file,
|
||||||
Some("md") => self.parse_obsidian_note(&path, &context)?,
|
context.current_file().display(),
|
||||||
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => {
|
);
|
||||||
self.make_link_to_file(¬e_name, ¬e_name, &context)
|
return Ok(vec![]);
|
||||||
.into_iter()
|
}
|
||||||
.map(|event| match event {
|
|
||||||
// make_link_to_file returns a link to a file. With this we turn the link
|
let path = path.unwrap();
|
||||||
// into an image reference instead. Slightly hacky, but avoids needing
|
let context = Context::from_parent(context, path);
|
||||||
// to keep another utility function around for this, or introducing an
|
let no_ext = OsString::new();
|
||||||
// extra parameter on make_link_to_file.
|
|
||||||
Event::Start(Tag::Link(linktype, cowstr1, cowstr2)) => {
|
let tree = match path.extension().unwrap_or(&no_ext).to_str() {
|
||||||
Event::Start(Tag::Image(
|
Some("md") => {
|
||||||
linktype,
|
let mut tree = self.parse_obsidian_note(&path, &context)?;
|
||||||
CowStr::from(cowstr1.into_string()),
|
if let Some(section) = note_ref.section {
|
||||||
CowStr::from(cowstr2.into_string()),
|
tree = reduce_to_section(tree, section);
|
||||||
))
|
|
||||||
}
|
|
||||||
Event::End(Tag::Link(linktype, cowstr1, cowstr2)) => {
|
|
||||||
Event::End(Tag::Image(
|
|
||||||
linktype,
|
|
||||||
CowStr::from(cowstr1.into_string()),
|
|
||||||
CowStr::from(cowstr2.into_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => event,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
_ => self.make_link_to_file(¬e_name, ¬e_name, &context),
|
|
||||||
}
|
}
|
||||||
|
tree
|
||||||
}
|
}
|
||||||
None => {
|
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => {
|
||||||
// TODO: Extract into configurable function.
|
self.make_link_to_file(¬e_ref.file, ¬e_ref.file, &context)
|
||||||
println!(
|
.into_iter()
|
||||||
"Warning: Unable to find embedded note\n\tReference: '{}'\n\tSource: '{}'\n",
|
.map(|event| match event {
|
||||||
note_name,
|
// make_link_to_file returns a link to a file. With this we turn the link
|
||||||
context.current_file().display(),
|
// into an image reference instead. Slightly hacky, but avoids needing
|
||||||
);
|
// to keep another utility function around for this, or introducing an
|
||||||
vec![]
|
// extra parameter on make_link_to_file.
|
||||||
|
Event::Start(Tag::Link(linktype, cowstr1, cowstr2)) => {
|
||||||
|
Event::Start(Tag::Image(
|
||||||
|
linktype,
|
||||||
|
CowStr::from(cowstr1.into_string()),
|
||||||
|
CowStr::from(cowstr2.into_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Event::End(Tag::Link(linktype, cowstr1, cowstr2)) => {
|
||||||
|
Event::End(Tag::Image(
|
||||||
|
linktype,
|
||||||
|
CowStr::from(cowstr1.into_string()),
|
||||||
|
CowStr::from(cowstr2.into_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => event,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
_ => self.make_link_to_file(¬e_ref.file, ¬e_ref.file, &context),
|
||||||
};
|
};
|
||||||
Ok(tree)
|
Ok(tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn obsidian_note_link_to_markdown(&self, content: &'a str, context: &Context) -> MarkdownTree {
|
fn obsidian_note_link_to_markdown(&self, content: &'a str, context: &Context) -> MarkdownTree {
|
||||||
let captures = OBSIDIAN_NOTE_LINK_RE
|
let note_ref = ObsidianNoteReference::from_str(content);
|
||||||
.captures(&content)
|
let label = note_ref.label.unwrap_or(note_ref.file);
|
||||||
.expect("note link regex didn't match - bad input?");
|
self.make_link_to_file(note_ref.file, label, context)
|
||||||
let notename = captures
|
|
||||||
.name("file")
|
|
||||||
.expect("Obsidian links should always reference a file");
|
|
||||||
let label = captures.name("label").unwrap_or(notename);
|
|
||||||
|
|
||||||
self.make_link_to_file(notename.as_str(), label.as_str(), context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_link_to_file<'b>(
|
fn make_link_to_file<'b>(
|
||||||
@ -569,6 +597,55 @@ fn is_markdown_file(file: &Path) -> bool {
|
|||||||
ext == "md"
|
ext == "md"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reduce a given `MarkdownTree` to just those elements which are children of the given section
|
||||||
|
/// (heading name).
|
||||||
|
fn reduce_to_section<'a, 'b>(tree: MarkdownTree<'a>, section: &'b str) -> MarkdownTree<'a> {
|
||||||
|
let mut new_tree = Vec::with_capacity(tree.len());
|
||||||
|
let mut target_section_encountered = false;
|
||||||
|
let mut currently_in_target_section = false;
|
||||||
|
let mut section_level = 0;
|
||||||
|
let mut last_level = 0;
|
||||||
|
let mut last_tag_was_heading = false;
|
||||||
|
|
||||||
|
for event in tree.into_iter() {
|
||||||
|
new_tree.push(event.clone());
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::Heading(level)) => {
|
||||||
|
last_tag_was_heading = true;
|
||||||
|
last_level = level;
|
||||||
|
if currently_in_target_section && level <= section_level {
|
||||||
|
currently_in_target_section = false;
|
||||||
|
new_tree.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(cowstr) => {
|
||||||
|
if !last_tag_was_heading {
|
||||||
|
last_tag_was_heading = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_tag_was_heading = false;
|
||||||
|
|
||||||
|
if cowstr.to_string().to_lowercase() == section.to_lowercase() {
|
||||||
|
target_section_encountered = true;
|
||||||
|
currently_in_target_section = true;
|
||||||
|
section_level = last_level;
|
||||||
|
|
||||||
|
let current_event = new_tree.pop().unwrap();
|
||||||
|
let heading_start_event = new_tree.pop().unwrap();
|
||||||
|
new_tree.clear();
|
||||||
|
new_tree.push(heading_start_event);
|
||||||
|
new_tree.push(current_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if target_section_encountered && !currently_in_target_section {
|
||||||
|
return new_tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_tree
|
||||||
|
}
|
||||||
|
|
||||||
fn event_to_owned<'a>(event: Event) -> Event<'a> {
|
fn event_to_owned<'a>(event: Event) -> Event<'a> {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(tag) => Event::Start(tag_to_owned(tag)),
|
Event::Start(tag) => Event::Start(tag_to_owned(tag)),
|
||||||
|
9
tests/testdata/expected/main-samples/embeds-partial-note.md
vendored
Normal file
9
tests/testdata/expected/main-samples/embeds-partial-note.md
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
## Heading
|
||||||
|
|
||||||
|
Second paragraph.
|
||||||
|
|
||||||
|
### Subheading
|
||||||
|
|
||||||
|
* One
|
||||||
|
* Two
|
||||||
|
* Three
|
1
tests/testdata/input/main-samples/embeds-partial-note.md
vendored
Normal file
1
tests/testdata/input/main-samples/embeds-partial-note.md
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
![[note-with-headings#heading]]
|
22
tests/testdata/input/main-samples/note-with-headings.md
vendored
Normal file
22
tests/testdata/input/main-samples/note-with-headings.md
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Title
|
||||||
|
|
||||||
|
First paragraph.
|
||||||
|
|
||||||
|
Heading
|
||||||
|
|
||||||
|
Don't delete the "Heading" paragraph above.
|
||||||
|
It's purpose is to make sure only an actual heading called _"Heading"_ is used.
|
||||||
|
|
||||||
|
## Heading
|
||||||
|
|
||||||
|
Second paragraph.
|
||||||
|
|
||||||
|
### Subheading
|
||||||
|
|
||||||
|
- One
|
||||||
|
- Two
|
||||||
|
- Three
|
||||||
|
|
||||||
|
## Heading
|
||||||
|
|
||||||
|
This section is also named heading.
|
Loading…
Reference in New Issue
Block a user