diff --git a/lib/livebook/notebook/export/elixir.ex b/lib/livebook/notebook/export/elixir.ex
new file mode 100644
index 000000000..68167973b
--- /dev/null
+++ b/lib/livebook/notebook/export/elixir.ex
@@ -0,0 +1,80 @@
+defmodule Livebook.Notebook.Export.Elixir do
+ alias Livebook.Notebook
+ alias Livebook.Notebook.Cell
+
+ @doc """
+ Converts the given notebook into a Elixir source code.
+ """
+ @spec notebook_to_elixir(Notebook.t()) :: String.t()
+ def notebook_to_elixir(notebook) do
+ iodata = render_notebook(notebook)
+ # Add trailing newline
+ IO.iodata_to_binary([iodata, "\n"])
+ end
+
+ defp render_notebook(notebook) do
+ name = ["# Title: ", notebook.name]
+ sections = Enum.map(notebook.sections, &render_section(&1, notebook))
+
+ [name | sections]
+ |> Enum.intersperse("\n\n")
+ end
+
+ defp render_section(section, notebook) do
+ name = ["# ── ", section.name, " ──"]
+
+ name =
+ if section.parent_id do
+ {:ok, parent} = Notebook.fetch_section(notebook, section.parent_id)
+ [name, " (⎇ from ", parent.name, ")"]
+ else
+ name
+ end
+
+ cells =
+ section.cells
+ |> Enum.map(&render_cell(&1, section))
+ |> Enum.reject(&(&1 == []))
+
+ [name | cells]
+ |> Enum.intersperse("\n\n")
+ end
+
+ defp render_cell(%Cell.Markdown{} = cell, _section) do
+ cell.source
+ |> Livebook.LiveMarkdown.MarkdownHelpers.reformat()
+ |> String.split("\n")
+ |> Enum.map_intersperse("\n", &comment_out/1)
+ end
+
+ defp render_cell(%Cell.Elixir{} = cell, section) do
+ code = get_elixir_cell_code(cell)
+
+ if section.parent_id do
+ code
+ |> IO.iodata_to_binary()
+ |> String.split("\n")
+ |> Enum.map_intersperse("\n", &comment_out/1)
+ else
+ code
+ end
+ end
+
+ defp render_cell(_cell, _section), do: []
+
+ defp comment_out(""), do: ""
+ defp comment_out(line), do: ["# ", line]
+
+ defp get_elixir_cell_code(%{source: source, metadata: %{"disable_formatting" => true}}),
+ do: source
+
+ defp get_elixir_cell_code(%{source: source}), do: format_code(source)
+
+ defp format_code(code) do
+ try do
+ Code.format_string!(code)
+ rescue
+ _ -> code
+ end
+ end
+end
diff --git a/lib/livebook_web/controllers/session_controller.ex b/lib/livebook_web/controllers/session_controller.ex
index 7e8ef1c7e..5f5848aed 100644
--- a/lib/livebook_web/controllers/session_controller.ex
+++ b/lib/livebook_web/controllers/session_controller.ex
@@ -14,20 +14,38 @@ defmodule LivebookWeb.SessionController do
end
end
- def download_source(conn, %{"id" => id}) do
+ def download_source(conn, %{"id" => id, "format" => format}) do
if SessionSupervisor.session_exists?(id) do
notebook = Session.get_notebook(id)
- source = Livebook.LiveMarkdown.Export.notebook_to_markdown(notebook)
- send_download(conn, {:binary, source},
- filename: "notebook.livemd",
- content_type: "text/plain"
- )
+ send_notebook_source(conn, notebook, format)
else
send_resp(conn, 404, "Not found")
end
end
+ defp send_notebook_source(conn, notebook, "livemd") do
+ source = Livebook.LiveMarkdown.Export.notebook_to_markdown(notebook)
+
+ send_download(conn, {:binary, source},
+ filename: "notebook.livemd",
+ content_type: "text/plain"
+ )
+ end
+
+ defp send_notebook_source(conn, notebook, "exs") do
+ source = Livebook.Notebook.Export.Elixir.notebook_to_elixir(notebook)
+
+ send_download(conn, {:binary, source},
+ filename: "notebook.exs",
+ content_type: "text/plain"
+ )
+ end
+
+ defp send_notebook_source(conn, _notebook, _format) do
+ send_resp(conn, 400, "Invalid format, supported formats: livemd, exs")
+ end
+
defp serve_static(conn, path) do
case put_cache_header(conn, path) do
{:stale, conn} ->
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex
index 6257ecf51..103f7be53 100644
--- a/lib/livebook_web/live/session_live.ex
+++ b/lib/livebook_web/live/session_live.ex
@@ -202,7 +202,7 @@ defmodule LivebookWeb.SessionLive do
<.remix_icon icon="dashboard-2-line" />
See on Dashboard
- <%= live_patch to: Routes.session_path(@socket, :export, @session_id),
+ <%= live_patch to: Routes.session_path(@socket, :export, @session_id, "livemd"),
class: "menu__item text-gray-500" do %>
<.remix_icon icon="download-2-line" />
Export
@@ -323,6 +323,7 @@ defmodule LivebookWeb.SessionLive do
id: "export",
modal_class: "w-full max-w-4xl",
session_id: @session_id,
+ tab: @tab,
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
"""
@@ -361,6 +362,10 @@ defmodule LivebookWeb.SessionLive do
{:noreply, handle_relative_path(socket, path)}
end
+ def handle_params(%{"tab" => tab}, _url, socket) do
+ {:noreply, assign(socket, tab: tab)}
+ end
+
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
diff --git a/lib/livebook_web/live/session_live/export_component.ex b/lib/livebook_web/live/session_live/export_component.ex
index 9739608d5..6ca2a4ba1 100644
--- a/lib/livebook_web/live/session_live/export_component.ex
+++ b/lib/livebook_web/live/session_live/export_component.ex
@@ -8,14 +8,13 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
socket = assign(socket, assigns)
socket =
- if socket.assigns[:source] do
+ if socket.assigns[:notebook] do
socket
else
# Note: we need to load the notebook, because the local data
# has cell contents stripped out
notebook = Session.get_notebook(socket.assigns.session_id)
- source = Livebook.LiveMarkdown.Export.notebook_to_markdown(notebook)
- assign(socket, :source, source)
+ assign(socket, :notebook, notebook)
end
{:ok, socket}
@@ -32,38 +31,30 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
Here you can preview and directly export the notebook source.
-
-
-
- .livemd
+
+ <%= live_patch to: Routes.session_path(@socket, :export, @session_id, "livemd"),
+ class: "tab #{if(@tab == "livemd", do: "active")}" do %>
+
+ Live Markdown
-
-
-
+ <% end %>
+ <%= live_patch to: Routes.session_path(@socket, :export, @session_id, "exs"),
+ class: "tab #{if(@tab == "exs", do: "active")}" do %>
+
+ Elixir Script
+
+ <% end %>
+
+ <%= live_component component_for_tab(@tab),
+ session_id: @session_id,
+ notebook: @notebook %>
+
"""
end
+
+ defp component_for_tab("livemd"), do: LivebookWeb.SessionLive.ExportLiveMarkdownComponent
+ defp component_for_tab("exs"), do: LivebookWeb.SessionLive.ExportElixirComponent
end
diff --git a/lib/livebook_web/live/session_live/export_elixir_component.ex b/lib/livebook_web/live/session_live/export_elixir_component.ex
new file mode 100644
index 000000000..33787a400
--- /dev/null
+++ b/lib/livebook_web/live/session_live/export_elixir_component.ex
@@ -0,0 +1,61 @@
+defmodule LivebookWeb.SessionLive.ExportElixirComponent do
+ use LivebookWeb, :live_component
+
+ @impl true
+ def update(assigns, socket) do
+ socket = assign(socket, assigns)
+
+ source = Livebook.Notebook.Export.Elixir.notebook_to_elixir(socket.assigns.notebook)
+ socket = assign(socket, :source, source)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ Note:
+ the script export is available as a convenience, rather than
+ an exact reproduction of the notebook and in some cases it may
+ not even compile. For example, if you define a macro in one cell
+ and import it in another cell, it works fine in Livebook,
+ because each cell is compiled separately. However, when running
+ the script it gets compiled as a whole and consequently doing so
+ doesn't work. Additionally, branching sections are commented out.
+
+
+
+ """
+ end
+end
diff --git a/lib/livebook_web/live/session_live/export_live_markdown_component.ex b/lib/livebook_web/live/session_live/export_live_markdown_component.ex
new file mode 100644
index 000000000..70ccf1974
--- /dev/null
+++ b/lib/livebook_web/live/session_live/export_live_markdown_component.ex
@@ -0,0 +1,49 @@
+defmodule LivebookWeb.SessionLive.ExportLiveMarkdownComponent do
+ use LivebookWeb, :live_component
+
+ @impl true
+ def update(assigns, socket) do
+ socket = assign(socket, assigns)
+
+ source = Livebook.LiveMarkdown.Export.notebook_to_markdown(socket.assigns.notebook)
+ socket = assign(socket, :source, source)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+end
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index 53669da5a..fdf1b6f1e 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -34,8 +34,8 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
live "/sessions/:id/settings/file", SessionLive, :file_settings
live "/sessions/:id/bin", SessionLive, :bin
- live "/sessions/:id/export", SessionLive, :export
- get "/sessions/:id/export/download", SessionController, :download_source
+ get "/sessions/:id/export/download/:format", SessionController, :download_source
+ live "/sessions/:id/export/:tab", SessionLive, :export
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
live "/sessions/:id/cell-upload/:cell_id", SessionLive, :cell_upload
live "/sessions/:id/delete-section/:section_id", SessionLive, :delete_section
diff --git a/test/livebook/notebook/export/elixir_test.exs b/test/livebook/notebook/export/elixir_test.exs
new file mode 100644
index 000000000..5ac8d5c7b
--- /dev/null
+++ b/test/livebook/notebook/export/elixir_test.exs
@@ -0,0 +1,120 @@
+defmodule Livebook.Notebook.Export.ElixirTest do
+ use ExUnit.Case, async: true
+
+ alias Livebook.Notebook.Export
+ alias Livebook.Notebook
+
+ test "acceptance" do
+ notebook = %{
+ Notebook.new()
+ | name: "My Notebook",
+ metadata: %{"author" => "Sherlock Holmes"},
+ sections: [
+ %{
+ Notebook.Section.new()
+ | name: "Section 1",
+ metadata: %{"created_at" => "2021-02-15"},
+ cells: [
+ %{
+ Notebook.Cell.new(:markdown)
+ | metadata: %{"updated_at" => "2021-02-15"},
+ source: """
+ Make sure to install:
+
+ * Erlang
+ * Elixir
+ * PostgreSQL\
+ """
+ },
+ %{
+ Notebook.Cell.new(:elixir)
+ | metadata: %{"readonly" => true},
+ source: """
+ Enum.to_list(1..10)\
+ """
+ },
+ %{
+ Notebook.Cell.new(:markdown)
+ | metadata: %{},
+ source: """
+ This is it for this section.\
+ """
+ }
+ ]
+ },
+ %{
+ Notebook.Section.new()
+ | id: "s2",
+ name: "Section 2",
+ metadata: %{},
+ cells: [
+ %{
+ Notebook.Cell.new(:input)
+ | type: :text,
+ name: "length",
+ value: "100",
+ reactive: true
+ },
+ %{
+ Notebook.Cell.new(:elixir)
+ | metadata: %{},
+ source: """
+ IO.gets("length: ")\
+ """
+ },
+ %{
+ Notebook.Cell.new(:input)
+ | type: :range,
+ name: "length",
+ value: "100",
+ props: %{min: 50, max: 150, step: 2}
+ }
+ ]
+ },
+ %{
+ Notebook.Section.new()
+ | name: "Section 3",
+ metadata: %{},
+ parent_id: "s2",
+ cells: [
+ %{
+ Notebook.Cell.new(:elixir)
+ | metadata: %{},
+ source: """
+ Process.info()\
+ """
+ }
+ ]
+ }
+ ]
+ }
+
+ expected_document = """
+ # Title: My Notebook
+
+ # ── Section 1 ──
+
+ # Make sure to install:
+
+ # * Erlang
+ # * Elixir
+ # * PostgreSQL
+
+ Enum.to_list(1..10)
+
+ # This is it for this section.
+
+ # ── Section 2 ──
+
+ IO.gets("length: ")
+
+ # ── Section 3 ── (⎇ from Section 2)
+
+ # Process.info()
+ """
+
+ document = Export.Elixir.notebook_to_elixir(notebook)
+
+ assert expected_document == document
+ end
+end
diff --git a/test/livebook_web/controllers/session_controller_test.exs b/test/livebook_web/controllers/session_controller_test.exs
index 5f3ec888b..5cb63e8bd 100644
--- a/test/livebook_web/controllers/session_controller_test.exs
+++ b/test/livebook_web/controllers/session_controller_test.exs
@@ -39,16 +39,25 @@ defmodule LivebookWeb.SessionControllerTest do
describe "download_source" do
test "returns not found when the given session does not exist", %{conn: conn} do
- conn = get(conn, Routes.session_path(conn, :download_source, "nonexistent"))
+ conn = get(conn, Routes.session_path(conn, :download_source, "nonexistent", "livemd"))
assert conn.status == 404
assert conn.resp_body == "Not found"
end
- test "returns live markdown notebook source", %{conn: conn} do
+ test "returns bad request when given an invalid format", %{conn: conn} do
{:ok, session_id} = SessionSupervisor.create_session()
- conn = get(conn, Routes.session_path(conn, :download_source, session_id))
+ conn = get(conn, Routes.session_path(conn, :download_source, session_id, "invalid"))
+
+ assert conn.status == 400
+ assert conn.resp_body == "Invalid format, supported formats: livemd, exs"
+ end
+
+ test "handles live markdown notebook source", %{conn: conn} do
+ {:ok, session_id} = SessionSupervisor.create_session()
+
+ conn = get(conn, Routes.session_path(conn, :download_source, session_id, "livemd"))
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["text/plain"]
@@ -59,5 +68,20 @@ defmodule LivebookWeb.SessionControllerTest do
SessionSupervisor.close_session(session_id)
end
+
+ test "handles elixir notebook source", %{conn: conn} do
+ {:ok, session_id} = SessionSupervisor.create_session()
+
+ conn = get(conn, Routes.session_path(conn, :download_source, session_id, "exs"))
+
+ assert conn.status == 200
+ assert get_resp_header(conn, "content-type") == ["text/plain"]
+
+ assert conn.resp_body == """
+ # Title: Untitled notebook
+ """
+
+ SessionSupervisor.close_session(session_id)
+ end
end
end