mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-12-29 19:20:46 +08:00
Add Elixir source export (#476)
This commit is contained in:
parent
45e8da0652
commit
cfbba9e2ce
9 changed files with 391 additions and 43 deletions
80
lib/livebook/notebook/export/elixir.ex
Normal file
80
lib/livebook/notebook/export/elixir.ex
Normal 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
|
|
@ -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} ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
120
test/livebook/notebook/export/elixir_test.exs
Normal file
120
test/livebook/notebook/export/elixir_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue