Add Elixir source export (#476)

This commit is contained in:
Jonatan Kłosko 2021-07-28 13:40:36 +02:00 committed by GitHub
parent 45e8da0652
commit cfbba9e2ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 391 additions and 43 deletions

View file

@ -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

View file

@ -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} ->

View file

@ -202,7 +202,7 @@ defmodule LivebookWeb.SessionLive do
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= 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" />
<span class="font-medium">Export</span>
@ -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

View file

@ -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
<p class="text-gray-700">
Here you can preview and directly export the notebook source.
</p>
<div class="flex flex-col space-y-1">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-700 font-semibold">
.livemd
<div class="tabs">
<%= live_patch to: Routes.session_path(@socket, :export, @session_id, "livemd"),
class: "tab #{if(@tab == "livemd", do: "active")}" do %>
<span class="font-medium">
Live Markdown
</span>
<div class="flex justify-end space-x-2">
<span class="tooltip left" aria-label="Copy source">
<button class="icon-button"
id="export-notebook-source-clipcopy"
phx-hook="ClipCopy"
data-target-id="export-notebook-source">
<.remix_icon icon="clipboard-line" class="text-lg" />
</button>
</span>
<span class="tooltip left" aria-label="Download source">
<a class="icon-button"
href={Routes.session_path(@socket, :download_source, @session_id)}>
<.remix_icon icon="download-2-line" class="text-lg" />
</a>
</span>
</div>
</div>
<div class="markdown">
<pre><code
class="tiny-scrollbar"
id="export-notebook-source"
phx-hook="Highlight"
data-language="markdown"><%= @source %></code></pre>
</div>
<% end %>
<%= live_patch to: Routes.session_path(@socket, :export, @session_id, "exs"),
class: "tab #{if(@tab == "exs", do: "active")}" do %>
<span class="font-medium">
Elixir Script
</span>
<% end %>
</div>
<div>
<%= live_component component_for_tab(@tab),
session_id: @session_id,
notebook: @notebook %>
</div>
</div>
</div>
"""
end
defp component_for_tab("livemd"), do: LivebookWeb.SessionLive.ExportLiveMarkdownComponent
defp component_for_tab("exs"), do: LivebookWeb.SessionLive.ExportElixirComponent
end

View file

@ -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"""
<div class="flex flex-col space-y-3">
<p class="text-gray-700">
<span class="font-semibold">Note:</span>
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.
</p>
<div class="flex flex-col space-y-1">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-700 font-semibold">
.exs
</span>
<div class="flex justify-end space-x-2">
<span class="tooltip left" aria-label="Copy source">
<button class="icon-button"
id="export-notebook-source-clipcopy"
phx-hook="ClipCopy"
data-target-id="export-notebook-source">
<.remix_icon icon="clipboard-line" class="text-lg" />
</button>
</span>
<span class="tooltip left" aria-label="Download source">
<a class="icon-button"
href={Routes.session_path(@socket, :download_source, @session_id, "exs")}>
<.remix_icon icon="download-2-line" class="text-lg" />
</a>
</span>
</div>
</div>
<div class="markdown">
<pre><code
class="tiny-scrollbar"
id="export-notebook-source"
phx-hook="Highlight"
data-language="elixir"><%= @source %></code></pre>
</div>
</div>
</div>
"""
end
end

View file

@ -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"""
<div class="flex flex-col space-y-1">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-700 font-semibold">
.livemd
</span>
<div class="flex justify-end space-x-2">
<span class="tooltip left" aria-label="Copy source">
<button class="icon-button"
id="export-notebook-source-clipcopy"
phx-hook="ClipCopy"
data-target-id="export-notebook-source">
<.remix_icon icon="clipboard-line" class="text-lg" />
</button>
</span>
<span class="tooltip left" aria-label="Download source">
<a class="icon-button"
href={Routes.session_path(@socket, :download_source, @session_id, "livemd")}>
<.remix_icon icon="download-2-line" class="text-lg" />
</a>
</span>
</div>
</div>
<div class="markdown">
<pre><code
class="tiny-scrollbar"
id="export-notebook-source"
phx-hook="Highlight"
data-language="markdown"><%= @source %></code></pre>
</div>
</div>
"""
end
end

View file

@ -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

View file

@ -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

View file

@ -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