diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 0384bb06f..541c149c0 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -157,6 +157,27 @@ defmodule Livebook.Session do GenServer.call(pid, :get_notebook, @timeout) end + @doc """ + Computes the file name for download. + + Note that the name doesn't have any extension. + + If the notebook has an associated file, the same name is used, + otherwise it is computed from the notebook title. + """ + @spec file_name_for_download(t()) :: String.t() + def file_name_for_download(session) + + def file_name_for_download(%{file: nil} = session) do + notebook_name_to_file_name(session.notebook_name) + end + + def file_name_for_download(session) do + session.file + |> FileSystem.File.name() + |> Path.rootname() + end + @doc """ Fetches assets matching the given hash. @@ -406,7 +427,8 @@ defmodule Livebook.Session do If there's a file set and the notebook changed since the last save, it will be persisted to said file. - Note that notebooks are automatically persisted every @autosave_interval milliseconds. + Note that notebooks are automatically persisted every @autosave_interval + milliseconds. """ @spec save(pid()) :: :ok def save(pid) do @@ -1237,11 +1259,7 @@ defmodule Livebook.Session do end defp default_notebook_path(state) do - title_str = - state.data.notebook.name - |> String.downcase() - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/[^\w]/, "") + title_str = notebook_name_to_file_name(state.data.notebook.name) # We want a random, but deterministic part, so we # use a few trailing characters from the session id, @@ -1257,6 +1275,17 @@ defmodule Livebook.Session do "#{date_str}/#{time_str}_#{title_str}_#{random_str}.livemd" end + defp notebook_name_to_file_name(notebook_name) do + notebook_name + |> String.downcase() + |> String.replace(~r/\s+/, "_") + |> String.replace(~r/[^\w]/, "") + |> case do + "" -> "untitled_notebook" + name -> name + end + end + defp handle_save_finished(state, result, file, default?) do state = if default? do diff --git a/lib/livebook_web/controllers/session_controller.ex b/lib/livebook_web/controllers/session_controller.ex index f1f7049c7..f2fa9c987 100644 --- a/lib/livebook_web/controllers/session_controller.ex +++ b/lib/livebook_web/controllers/session_controller.ex @@ -18,34 +18,35 @@ defmodule LivebookWeb.SessionController do case Sessions.fetch_session(id) do {:ok, session} -> notebook = Session.get_notebook(session.pid) + file_name = Session.file_name_for_download(session) - send_notebook_source(conn, notebook, format) + send_notebook_source(conn, notebook, file_name, format) :error -> send_resp(conn, 404, "Not found") end end - defp send_notebook_source(conn, notebook, "livemd") do + defp send_notebook_source(conn, notebook, file_name, "livemd" = format) do opts = [include_outputs: conn.params["include_outputs"] == "true"] source = Livebook.LiveMarkdown.Export.notebook_to_markdown(notebook, opts) send_download(conn, {:binary, source}, - filename: "notebook.livemd", + filename: file_name <> "." <> format, content_type: "text/plain" ) end - defp send_notebook_source(conn, notebook, "exs") do + defp send_notebook_source(conn, notebook, file_name, "exs" = format) do source = Livebook.Notebook.Export.Elixir.notebook_to_elixir(notebook) send_download(conn, {:binary, source}, - filename: "notebook.exs", + filename: file_name <> "." <> format, content_type: "text/plain" ) end - defp send_notebook_source(conn, _notebook, _format) do + defp send_notebook_source(conn, _notebook, _file_name, _format) do send_resp(conn, 400, "Invalid format, supported formats: livemd, exs") 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 index 7230e6526..10d16b560 100644 --- a/lib/livebook_web/live/session_live/export_elixir_component.ex +++ b/lib/livebook_web/live/session_live/export_elixir_component.ex @@ -1,6 +1,8 @@ defmodule LivebookWeb.SessionLive.ExportElixirComponent do use LivebookWeb, :live_component + alias Livebook.Session + @impl true def update(assigns, socket) do socket = assign(socket, assigns) @@ -28,7 +30,7 @@ defmodule LivebookWeb.SessionLive.ExportElixirComponent do
- .exs + <%= Session.file_name_for_download(@session) <> ".exs" %>
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 index 797bc8397..be855a53c 100644 --- a/lib/livebook_web/live/session_live/export_live_markdown_component.ex +++ b/lib/livebook_web/live/session_live/export_live_markdown_component.ex @@ -1,6 +1,8 @@ defmodule LivebookWeb.SessionLive.ExportLiveMarkdownComponent do use LivebookWeb, :live_component + alias Livebook.Session + @impl true def update(assigns, socket) do socket = assign(socket, assigns) @@ -35,7 +37,7 @@ defmodule LivebookWeb.SessionLive.ExportLiveMarkdownComponent do
- .livemd + <%= Session.file_name_for_download(@session) <> ".livemd" %>
diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index 5facc7b7b..691d511d0 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -9,6 +9,25 @@ defmodule Livebook.SessionTest do %{session: session} end + describe "file_name_for_download/1" do + @tag :tmp_dir + test "uses associated file name if one is attached", %{tmp_dir: tmp_dir} do + tmp_dir = FileSystem.File.local(tmp_dir <> "/") + file = FileSystem.File.resolve(tmp_dir, "my_notebook.livemd") + session = start_session(file: file) + + assert Session.file_name_for_download(session) == "my_notebook" + end + + test "defaults to notebook name", %{session: session} do + Session.set_notebook_name(session.pid, "Cat's guide to life!") + # Get the updated struct + session = Session.get_by_pid(session.pid) + + assert Session.file_name_for_download(session) == "cats_guide_to_life" + end + end + describe "set_notebook_attributes/2" do test "sends an attributes update to subscribers", %{session: session} do Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")