Publishing Livebooks to Obsidian
The problem #
I want to get notes created in a Livebook into my Obsidian vault. I could just click the “Export” link, then copy and paste them into Obsidian, but publishing a Livebook from within a Livebook is an interesting problem.
The Livebook ENV variable #
Let’s inspect the variable:
inspect(__ENV__)
# Difficult to read:
# "#Macro.Env<aliases: [], context: nil, context_modules: [], file:...
In Elixir, __ENV__ is a special macro that returns a %Macro.ENV{} struct that contains the compile-time environment information for the current execution context.
In a Livebook context, __ENV__ is similar to the standard Elixir __ENV__, but:
- it has dynamic evaluation: in a pre-compiled Mix project,
__ENV__is fixed ad compilation. Livebook compiles cell-by-cell.__ENV__contains the environment state at the time a specific cell is evaluated.
# Make the contents easier to read:
inspect(__ENV__)
|> String.replace(", ", "\n")
|> IO.puts()
Get the Livebook’s file path #
Get the Livebook’s file path. Then get the file’s content. Extract the top level heading from the content and use it to generate the Obsidian note title.
vault_path = "/home/scossar/obsidian_vault/"
livebook_path =
__ENV__.file
|> String.split("#cell", parts: 2)
|> hd()
[header, content] =
livebook_path
|> File.read!()
|> String.split("\n\n", parts: 2)
[_level, title] = String.split(header, " ", parts: 2)
Publish to Obsidian! #
First test. Using the same as the above, but with pattern matching and assertions to make sure my assumptions about the content’s structure are true.
vault_path = "/home/scossar/obsidian_vault/"
[livebook_path, cell_id] =
String.split(__ENV__.file, "#cell:", parts: 2)
true = cell_id != ""
true = String.ends_with?(livebook_path, ".livemd")
# `"# " <> title` checks the heading prefix and extracts the title
["# " <> title, content] =
livebook_path
|> File.read!()
|> String.split("\n\n", parts: 2)
title = String.trim(title)
true = String.trim(title) != ""
false = String.contains?(title, ["/", ":", "\\", "\n", "\r", <<0>>])
true = String.trim(content) != ""
output_path = Path.join(vault_path, title <> ".md")
File.write!(output_path, content)
Create an Obsidian module #
Create an Obsidian module for publishing Livebooks to Obsidian.
Remove Livebook HTML comments from the markdown.
If the output_path already exists in the Obsidian vault, require the force: true option to be set.
Open the note in the Obsidian vault.
defmodule Obsidian do
def publish(vault_path, opts \\ []) do
force? = Keyword.get(opts, :force, false)
[livebook_path, _cell_id] =
String.split(__ENV__.file, "#cell:", parts: 2)
true = String.ends_with?(livebook_path, ".livemd")
# `"# " <> title` checks the heading prefix and extracts the title
["# " <> title, content] =
livebook_path
|> File.read!()
|> String.split("\n\n", parts: 2)
title = String.trim(title)
true = String.trim(title) != ""
false = String.contains?(title, ["/", ":", "\\", "\n", "\r", <<0>>])
true = String.trim(content) != ""
content = clean_content(content)
output_path = Path.join(vault_path, title <> ".md")
if note_exists?(output_path) and not force? do
IO.puts("`#{output_path}` exists.\nPublish with `force: true` to overwrite.")
{:error, :already_exists}
else
File.write!(output_path, content)
case open_note("obsidian_vault", Path.relative_to(output_path, vault_path)) do
:ok ->
IO.puts("Published and opened.")
{:error, {_status, output}} ->
IO.puts("Published, but couldn't open the note:\n#{output}")
end
end
end
defp note_exists?(path) do
case File.stat(path) do
{:ok, _stat} ->
true
{:error, :enoent} ->
false
{:error, reason} ->
raise File.Error,
reason: reason,
action: "inspect note",
path: path
end
end
defp clean_content(content) do
reg =
~r/\n<!--[ \t]*livebook:\s*\{\s*"(?:force_markdown|break_markdown)"\s*:\s*true\s*\}\s*-->\n/
String.replace(content, reg, "")
end
defp open_note(vault_name, relative_path) do
query =
URI.encode_query(
%{"vault" => vault_name, "file" => relative_path, "paneType" => "tab"},
:rfc3986
)
case System.cmd("xdg-open", ["obsidian://open?" <> query], stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, status} -> {:error, {status, output}}
end
end
end
vault_path = "/home/scossar/obsidian_vault"
Obsidian.publish(vault_path, force: true) # using the `force` option to update the note on Obsidian
Put the code into a Mix project #
Put the code into a Mix project so that it can be used as a Livebook dependency.
The project is here: https://github.com/scossar/obsidian_livebook
Add the following to a Livebook’s setup cell to use it as a dependency:
Mix.install([
{:obsidian_livebook, path: "/absolute/path/to/obsidian_livebook"}
])
The Livebook’s ENV isn’t the Mix project’s ENV #
The original implementation of ObsidianLivebook included __ENV__ in the module. That was making the assumption that __ENV__ would always be the Livebook’s env. I was ignoring what I’d written:
In Elixir,
__ENV__is a special macro that returns a%Macro.ENV{}struct that contains the compile-time environment information for the current execution context.In a Livebook context,
__ENV__is similar to the standard Elixir__ENV__, but:
- it has dynamic evaluation: in a pre-compiled Mix project,
__ENV__is fixed ad compilation. Livebook compiles cell-by-cell.__ENV__contains the environment state at the time a specific cell is evaluated.
The fix is to pass the source file (some_livebook.livemd) as an argument to ObsidianLivebook.publish. From withing a Livebook, this can be done with:
# `publish` takes care of stripping `"#cell:"` from the file name.
ObsidianLivebook.publish("/path/to/vault", __ENV__.file)
From IEx or a script:
ObsidianLivebook.publish("/path/to/vault", "/path/to/saved/livebook.livemd")
The Mix project ObsidianLivebook #
defmodule ObsidianLivebook do
@moduledoc """
Publish a Livebook to an Obsidian vault.
"""
@doc ~S"""
Publish the current Livebook to an Obsidian vault.
## Examples
vault_path = "/home/scossar/obsidian_vault"
# from within a Livebook
Obsidian.publish(vault_path, __ENV__.file)
Published and opened.
:ok
Obsidian.publish(vault_path, __ENV__.file)
`/home/scossar/obsidian_vault/Publishing Livebooks to Obsidian.md` exists.
Publish with `force: true` to overwrite.
{:error, :already_exists}
Obsidian.publish(vault_path, __ENV__.file, force: true)
Published and opened.
:ok
# From IEx
iex> ObsidianLivebook.publish("/path/to/obsidian_vault", "/path/to/livebook_publish_test_two.livemd", force: true)
Published and opened.
:ok
"""
def publish(vault_path, source_file, opts \\ []) do
force? = Keyword.get(opts, :force, false)
livebook_path =
source_file
|> String.split("#cell:", parts: 2)
|> hd()
true = String.ends_with?(livebook_path, ".livemd")
# `"# " <> title` checks the heading prefix and extracts the title
["# " <> title, content] =
livebook_path
|> File.read!()
|> String.split("\n\n", parts: 2)
title = String.trim(title)
true = String.trim(title) != ""
false = String.contains?(title, ["/", ":", "\\", "\n", "\r", <<0>>])
true = String.trim(content) != ""
content = clean_content(content)
output_path = Path.join(vault_path, title <> ".md")
if note_exists?(output_path) and not force? do
IO.puts("`#{output_path}` exists.\nPublish with `force: true` to overwrite.")
{:error, :already_exists}
else
File.write!(output_path, content)
case open_note("obsidian_vault", Path.relative_to(output_path, vault_path)) do
:ok ->
IO.puts("Published and opened.")
{:error, {_status, output}} ->
IO.puts("Published, but couldn't open the note:\n#{output}")
end
end
end
defp note_exists?(path) do
case File.stat(path) do
{:ok, _stat} ->
true
{:error, :enoent} ->
false
{:error, reason} ->
raise File.Error,
reason: reason,
action: "inspect note",
path: path
end
end
defp clean_content(content) do
reg =
~r/\n<!--[ \t]*livebook:\s*\{\s*"(?:force_markdown|break_markdown)"\s*:\s*true\s*\}\s*-->\n/
String.replace(content, reg, "")
end
defp open_note(vault_name, relative_path) do
query =
URI.encode_query(
%{"vault" => vault_name, "file" => relative_path, "paneType" => "tab"},
:rfc3986
)
case System.cmd("xdg-open", ["obsidian://open?" <> query], stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, status} -> {:error, {status, output}}
end
end
end