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 -
- - - - - - <.remix_icon icon="download-2-line" class="text-lg" /> - - -
-
-
-
<%= @source %>
-
+ <% 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. +

+
+
+ + .exs + +
+ + + + + + <.remix_icon icon="download-2-line" class="text-lg" /> + + +
+
+
+
<%= @source %>
+
+
+
+ """ + 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""" +
+
+ + .livemd + +
+ + + + + + <.remix_icon icon="download-2-line" class="text-lg" /> + + +
+
+
+
<%= @source %>
+
+
+ """ + 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