1251 lines
39 KiB
Elixir
1251 lines
39 KiB
Elixir
defmodule BDS.Desktop.ShellLive do
|
|
@moduledoc false
|
|
|
|
use Phoenix.LiveView
|
|
|
|
import Phoenix.HTML
|
|
|
|
alias BDS.{AI, BoundedAtoms, Metadata}
|
|
alias BDS.CliSync.Watcher
|
|
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
|
|
|
alias BDS.Desktop.ShellLive.{
|
|
Bridges,
|
|
ChatEditor,
|
|
GalleryImport,
|
|
ImportEditor,
|
|
MediaEditor,
|
|
MenuEditor,
|
|
MiscEditor,
|
|
OverlayManager,
|
|
ScriptEditor,
|
|
SettingsEditor,
|
|
SidebarDelete,
|
|
TagsEditor,
|
|
TemplateEditor
|
|
}
|
|
|
|
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
|
|
alias BDS.Desktop.ShellLive.PostEditor
|
|
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
|
alias BDS.Desktop.ShellLive.SidebarEvents
|
|
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
|
|
|
|
alias BDS.Desktop.ShellLive.{
|
|
ChatSurface,
|
|
Layout,
|
|
PanelRenderer,
|
|
SessionUtil,
|
|
ShellCommandRunner,
|
|
SidebarCreate,
|
|
TabHelpers,
|
|
TaskLocalization,
|
|
TitlebarMenu
|
|
}
|
|
|
|
import TaskLocalization,
|
|
only: [
|
|
localize_task_status: 2
|
|
]
|
|
|
|
import TabHelpers,
|
|
only: [
|
|
tab_id_for_route: 2,
|
|
tab_intent: 2,
|
|
sidebar_route_atom: 1
|
|
]
|
|
|
|
alias BDS.Projects
|
|
alias BDS.UI.{Commands, MenuBar, Session, Workbench}
|
|
alias Desktop.OS
|
|
alias BDS.Desktop.Shutdown
|
|
use Gettext, backend: BDS.Gettext
|
|
|
|
@refresh_interval 1_500
|
|
|
|
def refresh_interval, do: @refresh_interval
|
|
|
|
@output_entry_limit 20
|
|
@sidebar_filter_events [
|
|
"toggle_sidebar_filters",
|
|
"toggle_sidebar_archive",
|
|
"toggle_sidebar_tags",
|
|
"toggle_sidebar_categories",
|
|
"update_sidebar_search",
|
|
"clear_sidebar_search",
|
|
"clear_sidebar_tags",
|
|
"clear_sidebar_categories",
|
|
"toggle_sidebar_tag",
|
|
"toggle_sidebar_category",
|
|
"select_sidebar_year",
|
|
"select_sidebar_month",
|
|
"clear_sidebar_month",
|
|
"clear_sidebar_filters",
|
|
"load_more_sidebar"
|
|
]
|
|
|
|
@layout_menu_actions MapSet.new([
|
|
:toggle_sidebar,
|
|
:toggle_panel,
|
|
:toggle_assistant_sidebar,
|
|
:close_tab
|
|
])
|
|
@sidebar_menu_actions MapSet.new([
|
|
:view_posts,
|
|
:view_media,
|
|
:edit_preferences,
|
|
:edit_menu,
|
|
:documentation,
|
|
:api_documentation
|
|
])
|
|
@local_menu_actions MapSet.union(@layout_menu_actions, @sidebar_menu_actions)
|
|
@socket_menu_actions MapSet.new([
|
|
:new_post,
|
|
:import_media,
|
|
:save,
|
|
:publish_selected,
|
|
:quit,
|
|
:view_on_github,
|
|
:report_issue,
|
|
:about
|
|
])
|
|
@runtime_menu_actions MapSet.new([
|
|
:undo,
|
|
:redo,
|
|
:cut,
|
|
:copy,
|
|
:paste,
|
|
:delete,
|
|
:select_all,
|
|
:find,
|
|
:replace,
|
|
:reload,
|
|
:force_reload,
|
|
:reset_zoom,
|
|
:zoom_in,
|
|
:zoom_out,
|
|
:toggle_full_screen
|
|
])
|
|
|
|
def supported_menu_actions do
|
|
@local_menu_actions
|
|
|> MapSet.union(@socket_menu_actions)
|
|
|> MapSet.union(@runtime_menu_actions)
|
|
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|
|
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|
|
|> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
|
|
|> MapSet.union(
|
|
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
|
)
|
|
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
|
|
end
|
|
|
|
embed_templates("shell_live/*")
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
connected = connected?(socket)
|
|
|
|
if connected do
|
|
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
|
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
|
end
|
|
|
|
workbench = Workbench.new()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, ShellData.title())
|
|
|> assign(:page_language, ShellData.ui_language())
|
|
|> assign(:client_shortcuts, Commands.client_shortcuts())
|
|
|> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true))
|
|
|> assign(:handled_task_results, SessionUtil.initial_handled_task_results())
|
|
|> assign(:assistant_prompt, "")
|
|
|> assign(:assistant_messages, [])
|
|
|> assign(:is_mac_ui, mac_ui?())
|
|
|> assign(:menu_groups, TitlebarMenu.groups())
|
|
|> assign(:titlebar_menu_group, nil)
|
|
|> assign(:titlebar_menu_item_index, nil)
|
|
|> assign(:tab_meta, %{})
|
|
|> assign(:project_menu_open, false)
|
|
|> assign(:sidebar_filters_by_view, %{})
|
|
|> assign(:sidebar_filter_panels, %{})
|
|
|> assign(:chat_editor_request_refs, %{})
|
|
|> assign(:file_picker_task, nil)
|
|
|> assign(:shell_overlay, nil)
|
|
|> assign(:output_entries, [])
|
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|
|> assign(:panel_git_entries, [])
|
|
|> reload_shell(workbench)
|
|
|> apply_url_params(params)
|
|
|> tap(&sync_menu_bar_locale/1)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_sidebar", _params, socket) do
|
|
{:noreply, refresh_layout(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
|
end
|
|
|
|
def handle_event("toggle_panel", _params, socket) do
|
|
{:noreply, refresh_layout(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
|
end
|
|
|
|
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
|
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
|
end
|
|
|
|
def handle_event("select_view", %{"view" => view_id}, socket) do
|
|
workbench =
|
|
Workbench.click_activity(
|
|
socket.assigns.workbench,
|
|
BoundedAtoms.sidebar_view(view_id, :posts)
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> refresh_sidebar(workbench)
|
|
|> push_url_state()}
|
|
end
|
|
|
|
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
|
workbench =
|
|
socket.assigns.workbench
|
|
|> Workbench.set_panel_visible(true)
|
|
|> Workbench.set_panel_tab(BoundedAtoms.panel_tab(tab, :tasks))
|
|
|
|
{:noreply, refresh_layout(socket, workbench)}
|
|
end
|
|
|
|
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
|
{:noreply, open_sidebar_item(socket, params, :preview)}
|
|
end
|
|
|
|
def handle_event("pin_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
|
{:noreply, open_sidebar_item(socket, params, :pin)}
|
|
end
|
|
|
|
def handle_event("sync_layout", params, socket) do
|
|
{:noreply, refresh_layout(socket, Layout.sync(socket.assigns.workbench, params))}
|
|
end
|
|
|
|
def handle_event("resize_panel", %{"target" => target, "width" => width}, socket) do
|
|
{:noreply, refresh_layout(socket, Layout.resize(socket.assigns.workbench, target, width))}
|
|
end
|
|
|
|
def handle_event(event, params, socket) when event in @sidebar_filter_events do
|
|
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
|
end
|
|
|
|
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
|
{:noreply, create_sidebar_item(socket, kind)}
|
|
end
|
|
|
|
def handle_event("shortcut", params, socket) do
|
|
if Layout.ignore_shortcut?(params) do
|
|
{:noreply, socket}
|
|
else
|
|
case Commands.command_for_shortcut(params) do
|
|
nil -> {:noreply, socket}
|
|
action -> {:noreply, handle_menu_action(socket, action)}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
|
workbench =
|
|
Workbench.open_tab(
|
|
socket.assigns.workbench,
|
|
BoundedAtoms.editor_route(type, :post),
|
|
id,
|
|
:preview
|
|
)
|
|
|
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> refresh_layout(workbench)
|
|
|> push_url_state()}
|
|
end
|
|
|
|
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
|
type_atom = BoundedAtoms.editor_route(type, :post)
|
|
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
|
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> refresh_layout(workbench)
|
|
|> push_url_state()}
|
|
end
|
|
|
|
def handle_event(
|
|
"confirm_sidebar_delete",
|
|
%{"route" => route, "id" => id} = params,
|
|
socket
|
|
) do
|
|
{:noreply,
|
|
SidebarDelete.request_delete(
|
|
socket,
|
|
route,
|
|
id,
|
|
Map.get(params, "title"),
|
|
sidebar_delete_callbacks()
|
|
)}
|
|
end
|
|
|
|
def handle_event("toggle_offline_mode", _params, socket) do
|
|
next_mode = not socket.assigns.offline_mode
|
|
|
|
:ok = AI.set_airplane_mode(next_mode)
|
|
socket = assign(socket, :offline_mode, next_mode)
|
|
|
|
{:noreply, refresh_layout(socket, socket.assigns.workbench)}
|
|
end
|
|
|
|
def handle_event("update_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
|
|
{:noreply, assign(socket, :assistant_prompt, prompt)}
|
|
end
|
|
|
|
def handle_event("submit_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
|
|
prompt = prompt |> to_string() |> String.trim()
|
|
|
|
socket =
|
|
if prompt == "" do
|
|
assign(socket, :assistant_prompt, "")
|
|
else
|
|
socket
|
|
|> assign(:assistant_prompt, "")
|
|
|> assign(
|
|
:assistant_messages,
|
|
socket.assigns.assistant_messages ++ ChatSurface.assistant_turn(prompt, socket)
|
|
)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("open_tasks_panel", _params, socket) do
|
|
workbench =
|
|
socket.assigns.workbench
|
|
|> Workbench.set_panel_visible(true)
|
|
|> Workbench.set_panel_tab(:tasks)
|
|
|
|
{:noreply, refresh_layout(socket, workbench)}
|
|
end
|
|
|
|
def handle_event("settings_shell_command", %{"action" => action}, socket) do
|
|
{:noreply, apply_shell_command(socket, action)}
|
|
end
|
|
|
|
def handle_event("open_overlay", params, socket),
|
|
do: OverlayManager.handle_event("open_overlay", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("close_overlay", params, socket),
|
|
do: OverlayManager.handle_event("close_overlay", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_keydown", params, socket),
|
|
do: OverlayManager.handle_event("overlay_keydown", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_toggle_ai_field", params, socket),
|
|
do:
|
|
OverlayManager.handle_event("overlay_toggle_ai_field", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_set_search", params, socket),
|
|
do: OverlayManager.handle_event("overlay_set_search", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_set_tab", params, socket),
|
|
do: OverlayManager.handle_event("overlay_set_tab", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_update_form", params, socket),
|
|
do: OverlayManager.handle_event("overlay_update_form", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_select_result", params, socket),
|
|
do: OverlayManager.handle_event("overlay_select_result", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_insert_external", params, socket),
|
|
do:
|
|
OverlayManager.handle_event("overlay_insert_external", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_select_language", params, socket),
|
|
do:
|
|
OverlayManager.handle_event("overlay_select_language", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_confirm", params, socket),
|
|
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_select_gallery_image", params, socket),
|
|
do:
|
|
OverlayManager.handle_event(
|
|
"overlay_select_gallery_image",
|
|
params,
|
|
socket,
|
|
overlay_callbacks()
|
|
)
|
|
|
|
def handle_event("overlay_close_lightbox", params, socket),
|
|
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("overlay_lightbox_previous", params, socket),
|
|
do:
|
|
OverlayManager.handle_event(
|
|
"overlay_lightbox_previous",
|
|
params,
|
|
socket,
|
|
overlay_callbacks()
|
|
)
|
|
|
|
def handle_event("overlay_lightbox_next", params, socket),
|
|
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
|
|
|
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
|
if socket.assigns.offline_mode do
|
|
{:noreply,
|
|
append_output_entry(
|
|
socket,
|
|
dgettext("ui", "Add Gallery Images"),
|
|
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
|
nil,
|
|
"info"
|
|
)}
|
|
else
|
|
project_id = socket.assigns.projects.active_project_id
|
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
|
concurrency_limit = metadata.image_import_concurrency
|
|
language = metadata.main_language || "en"
|
|
parent = self()
|
|
|
|
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
|
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
|
image_only: true, multiple: true) do
|
|
{:ok, paths} when is_list(paths) and paths != [] ->
|
|
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
|
|
|
:cancel ->
|
|
send(parent, {:add_images_cancelled})
|
|
|
|
{:error, reason} ->
|
|
send(parent, {:add_images_error, reason})
|
|
end
|
|
end)
|
|
|
|
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
|
|
end
|
|
end
|
|
|
|
def handle_event("toggle_project_menu", _params, socket) do
|
|
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
|
|
end
|
|
|
|
def handle_event("close_project_menu", _params, socket) do
|
|
{:noreply, assign(socket, :project_menu_open, false)}
|
|
end
|
|
|
|
def handle_event("select_project", %{"project_id" => project_id}, socket) do
|
|
{:noreply,
|
|
activate_project(socket, project_id, "Select Project", fn project ->
|
|
"Activated #{project.name}"
|
|
end)}
|
|
end
|
|
|
|
def handle_event("create_project", _params, socket) do
|
|
attrs = %{name: SessionUtil.next_project_name(socket.assigns.projects.projects)}
|
|
|
|
socket =
|
|
case Projects.create_project(attrs) do
|
|
{:ok, project} ->
|
|
activate_project(socket, project.id, "New Project", fn created ->
|
|
"Activated #{created.name}"
|
|
end)
|
|
|
|
{:error, reason} ->
|
|
append_output_entry(socket, "New Project", inspect(reason), nil, "error")
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("import_project", _params, socket) do
|
|
socket =
|
|
case FolderPicker.choose_directory("Open Existing Blog") do
|
|
{:ok, path} ->
|
|
name =
|
|
path
|
|
|> Path.basename()
|
|
|> String.trim()
|
|
|> case do
|
|
"" -> "Imported Blog"
|
|
value -> value
|
|
end
|
|
|
|
case Projects.create_project(%{name: name, data_path: path}) do
|
|
{:ok, project} ->
|
|
activate_project(socket, project.id, "Open Existing Blog", fn imported ->
|
|
"Activated #{imported.name}"
|
|
end)
|
|
|
|
{:error, reason} ->
|
|
append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error")
|
|
end
|
|
|
|
:cancel ->
|
|
assign(socket, :project_menu_open, false)
|
|
|
|
{:error, %{message: message}} ->
|
|
append_output_entry(socket, "Open Existing Blog", message, nil, "error")
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("change_ui_language", %{"ui_language" => language}, socket) do
|
|
{:noreply, set_page_language(socket, language)}
|
|
end
|
|
|
|
def handle_event("sync_ui_language", %{"language" => language}, socket) do
|
|
{:noreply, set_page_language(socket, language)}
|
|
end
|
|
|
|
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
|
|
when is_map(session_payload) do
|
|
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
|
|
end
|
|
|
|
def handle_event("native_menu_action", %{"action" => action}, socket) do
|
|
{:noreply, handle_native_menu_action(socket, action)}
|
|
end
|
|
|
|
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
|
|
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
|
|
end
|
|
|
|
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
|
|
{:noreply, TitlebarMenu.toggle(socket, group)}
|
|
end
|
|
|
|
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
|
|
{:noreply, TitlebarMenu.hover(socket, group)}
|
|
end
|
|
|
|
def handle_event("close_titlebar_menu", _params, socket) do
|
|
{:noreply, TitlebarMenu.close(socket)}
|
|
end
|
|
|
|
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> TitlebarMenu.close()
|
|
|> handle_native_menu_action(action)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({ref, result}, socket) when is_reference(ref) do
|
|
Process.demonitor(ref, [:flush])
|
|
|
|
cond do
|
|
socket.assigns.file_picker_task == ref ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:file_picker_task, nil)
|
|
|> handle_file_picker_result(result)}
|
|
|
|
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
|
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
|
|
|
send_update(ChatEditor,
|
|
id: "chat-editor-#{conversation_id}",
|
|
action: :finish_request,
|
|
result: result
|
|
)
|
|
|
|
{:noreply, assign(socket, :chat_editor_request_refs, remaining_refs)}
|
|
|
|
true ->
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
|
|
next_socket =
|
|
cond do
|
|
socket.assigns.file_picker_task == ref ->
|
|
if reason == :normal do
|
|
assign(socket, :file_picker_task, nil)
|
|
else
|
|
socket
|
|
|> assign(:file_picker_task, nil)
|
|
|> append_output_entry(
|
|
dgettext("ui", "Import media"),
|
|
inspect(reason),
|
|
nil,
|
|
"error"
|
|
)
|
|
|> refresh_content(socket.assigns.workbench)
|
|
end
|
|
|
|
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
|
{conversation_id, remaining_refs} =
|
|
Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
|
|
|
if reason == :normal do
|
|
assign(socket, :chat_editor_request_refs, remaining_refs)
|
|
else
|
|
send_update(ChatEditor,
|
|
id: "chat-editor-#{conversation_id}",
|
|
action: :finish_request,
|
|
result: {:error, :cancelled}
|
|
)
|
|
|
|
assign(socket, :chat_editor_request_refs, remaining_refs)
|
|
end
|
|
|
|
true ->
|
|
socket
|
|
end
|
|
|
|
{:noreply, next_socket}
|
|
end
|
|
|
|
def handle_info({:ai_suggestions_result, type, id, result}, socket) do
|
|
OverlayManager.handle_info({:ai_suggestions_result, type, id, result}, socket)
|
|
end
|
|
|
|
def handle_info({:ai_suggestions_error, type, id, reason}, socket) do
|
|
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
|
|
end
|
|
|
|
def handle_info({:add_image_processed, title}, socket) do
|
|
{:noreply,
|
|
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
|
|
end
|
|
|
|
def handle_info({:add_images_complete, count}, socket) do
|
|
post_id = socket.assigns[:gallery_import_post_id]
|
|
|
|
socket =
|
|
if is_binary(post_id) do
|
|
send_update(PostEditor,
|
|
id: "post-editor-#{post_id}",
|
|
action: :insert_content,
|
|
content: "\n[[gallery]]\n"
|
|
)
|
|
|
|
send_update(PostEditor,
|
|
id: "post-editor-#{post_id}",
|
|
action: :refresh
|
|
)
|
|
|
|
socket
|
|
|> assign(:gallery_import_post_id, nil)
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> append_output_entry(
|
|
dgettext("ui", "Add Gallery Images"),
|
|
dgettext("ui", "Added %{count} images to post", count: count),
|
|
nil,
|
|
"info"
|
|
)}
|
|
end
|
|
|
|
def handle_info({:add_images_error, reason}, socket) do
|
|
{:noreply,
|
|
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
|
|
end
|
|
|
|
def handle_info({:add_image_error, path, reason}, socket) do
|
|
{:noreply,
|
|
append_output_entry(
|
|
socket,
|
|
dgettext("ui", "Add Gallery Images"),
|
|
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
|
|
nil,
|
|
"error"
|
|
)}
|
|
end
|
|
|
|
def handle_info({:add_images_cancelled}, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:test_ping, caller, ref}, socket) do
|
|
send(caller, {:test_pong, ref})
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info(message, socket) do
|
|
Bridges.handle_info(message, socket, bridges_callbacks())
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
UILocale.put(assigns.page_language)
|
|
index(assigns)
|
|
end
|
|
|
|
defp refresh_layout(socket, workbench) do
|
|
git_badge_count = socket.assigns[:git_badge_count] || 0
|
|
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
|
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
|
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
|
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
|
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
|
current_tab = current_tab(workbench)
|
|
prev_tab = socket.assigns[:current_tab]
|
|
prev_panel_tab =
|
|
case socket.assigns[:workbench] do
|
|
%Workbench{panel: %{active_tab: tab}} -> tab
|
|
_ -> nil
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:workbench, workbench)
|
|
|> assign(:activity_buttons, activity_buttons)
|
|
|> assign(
|
|
:sidebar_header,
|
|
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
|
|
)
|
|
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
|
|> assign(:current_tab, current_tab)
|
|
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|
|> assign(
|
|
:status,
|
|
ShellData.status_bar(workbench, task_status, dashboard,
|
|
ui_language: page_language,
|
|
offline_mode: offline_mode
|
|
)
|
|
)
|
|
|
|
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
|
|
refresh_panel_data(socket)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
|
|
current_tab != prev_tab or panel_tab != prev_panel_tab
|
|
end
|
|
|
|
defp refresh_panel_data(socket) do
|
|
panel_tab = socket.assigns.workbench.panel.active_tab
|
|
|
|
socket
|
|
|> assign(
|
|
:panel_post_links,
|
|
if(panel_tab == :post_links,
|
|
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
|
|
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
|
)
|
|
)
|
|
|> assign(
|
|
:panel_git_entries,
|
|
if(panel_tab == :git_log,
|
|
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
|
|
else: socket.assigns[:panel_git_entries] || []
|
|
)
|
|
)
|
|
end
|
|
|
|
defp push_url_state(socket) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
params =
|
|
%{}
|
|
|> put_url_view(workbench.active_view)
|
|
|> put_url_tab(workbench.active_tab)
|
|
|
|
query = URI.encode_query(params)
|
|
path = if query == "", do: "/", else: "/?" <> query
|
|
|
|
push_event(socket, "url-state", %{path: path})
|
|
end
|
|
|
|
defp put_url_view(params, :posts), do: params
|
|
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
|
|
|
|
defp put_url_tab(params, nil), do: params
|
|
|
|
defp put_url_tab(params, {type, id}),
|
|
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
|
|
|
|
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
|
|
workbench = socket.assigns.workbench
|
|
|
|
workbench = apply_url_view(workbench, Map.get(params, "view"))
|
|
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
|
|
|
|
if workbench == socket.assigns.workbench do
|
|
socket
|
|
else
|
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> refresh_sidebar(workbench)
|
|
end
|
|
end
|
|
|
|
defp apply_url_params(socket, _params), do: socket
|
|
|
|
defp apply_url_view(workbench, nil), do: workbench
|
|
|
|
defp apply_url_view(workbench, view_str) do
|
|
view = BoundedAtoms.sidebar_view(view_str, nil)
|
|
|
|
if view && view != workbench.active_view do
|
|
Workbench.click_activity(workbench, view)
|
|
else
|
|
workbench
|
|
end
|
|
end
|
|
|
|
defp apply_url_tab(workbench, nil), do: workbench
|
|
|
|
defp apply_url_tab(workbench, tab_str) do
|
|
case String.split(tab_str, ":", parts: 2) do
|
|
[type_str, id] ->
|
|
type = BoundedAtoms.editor_route(type_str, nil)
|
|
|
|
if type && workbench.active_tab != {type, id} do
|
|
Workbench.open_tab(workbench, type, id, :preview)
|
|
else
|
|
workbench
|
|
end
|
|
|
|
_ ->
|
|
workbench
|
|
end
|
|
end
|
|
|
|
defp refresh_sidebar(socket, workbench) do
|
|
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
|
|
active_view_id = Atom.to_string(workbench.active_view)
|
|
|
|
sidebar_data =
|
|
case ShellData.sidebar_view(
|
|
project_id,
|
|
active_view_id,
|
|
ShellSidebarState.current_filters(socket, active_view_id)
|
|
) do
|
|
{:ok, data} -> data
|
|
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
|
end
|
|
|
|
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
|
|
|
socket
|
|
|> assign(:sidebar_data, sidebar_data)
|
|
|> refresh_layout(workbench)
|
|
end
|
|
|
|
defp refresh_content(socket, workbench) do
|
|
projects =
|
|
case ShellData.project_snapshot() do
|
|
{:ok, data} -> data
|
|
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
|
end
|
|
|
|
dashboard =
|
|
case ShellData.dashboard(projects.active_project_id) do
|
|
{:ok, data} -> data
|
|
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
|
end
|
|
|
|
git_badge_count =
|
|
case ShellData.git_badge_count(projects.active_project_id) do
|
|
{:ok, count} -> count
|
|
{:error, :not_ready} -> 0
|
|
end
|
|
|
|
active_view_id = Atom.to_string(workbench.active_view)
|
|
|
|
sidebar_data =
|
|
case ShellData.sidebar_view(
|
|
projects.active_project_id,
|
|
active_view_id,
|
|
ShellSidebarState.current_filters(socket, active_view_id)
|
|
) do
|
|
{:ok, data} -> data
|
|
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
|
end
|
|
|
|
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
|
|
|
socket
|
|
|> assign(:projects, projects)
|
|
|> assign(:current_project, ShellData.current_project(projects))
|
|
|> assign(:dashboard, dashboard)
|
|
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
|
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
|
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
|
|> assign(
|
|
:dashboard_tag_cloud_items,
|
|
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
|
)
|
|
|> assign(:git_badge_count, git_badge_count)
|
|
|> assign(:sidebar_data, sidebar_data)
|
|
|> refresh_layout(workbench)
|
|
end
|
|
|
|
defp reload_shell(socket, workbench) do
|
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
raw_task_status = BDS.Tasks.status_snapshot()
|
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
|
|
|
offline_mode =
|
|
if connected?(socket) do
|
|
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
|
|
else
|
|
Map.get(socket.assigns, :offline_mode, true)
|
|
end
|
|
|
|
task_status = localize_task_status(raw_task_status, page_language)
|
|
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> assign(:task_status, task_status)
|
|
|> assign(:offline_mode, offline_mode)
|
|
|> assign(:assistant_cards, ShellData.assistant_cards())
|
|
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
|
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
|
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
|
|> refresh_content(workbench)
|
|
end
|
|
|
|
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
|
|
|
defp encoded_workbench_session(workbench), do: Jason.encode!(Session.serialize(workbench))
|
|
|
|
defp panel_tab_label(:tasks), do: dgettext("ui", "Tasks")
|
|
defp panel_tab_label(:output), do: dgettext("ui", "Output")
|
|
defp panel_tab_label(:git_log), do: dgettext("ui", "Git Log")
|
|
defp panel_tab_label(tab), do: ShellData.route_label(tab)
|
|
|
|
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
|
|
defp activity_label("Source Control"), do: dgettext("ui", "Git")
|
|
defp activity_label(label), do: label
|
|
|
|
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
|
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
|
|
if button.id == active_view, do: activity_label(button.label), else: nil
|
|
end)
|
|
end
|
|
|
|
defp sidebar_header_label(label), do: label
|
|
|
|
defp timeline_height(entry, entries) do
|
|
max_count =
|
|
entries
|
|
|> Enum.map(&(&1.count || 0))
|
|
|> Enum.max(fn -> 1 end)
|
|
|
|
max(4, (entry.count || 0) / max_count * 100)
|
|
end
|
|
|
|
defp current_tab(%{active_tab: nil}), do: nil
|
|
|
|
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
|
|
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
|
end
|
|
|
|
defp create_sidebar_item(socket, kind),
|
|
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
|
|
|
defp handle_file_picker_result(socket, {:ok, _media}),
|
|
do: refresh_content(socket, socket.assigns.workbench)
|
|
|
|
defp handle_file_picker_result(socket, :cancel), do: socket
|
|
|
|
defp handle_file_picker_result(socket, {:error, %{message: message}}),
|
|
do:
|
|
socket
|
|
|> append_output_entry(dgettext("ui", "Import media"), message, nil, "error")
|
|
|> refresh_content(socket.assigns.workbench)
|
|
|
|
defp handle_file_picker_result(socket, {:error, reason}),
|
|
do:
|
|
socket
|
|
|> append_output_entry(dgettext("ui", "Import media"), inspect(reason), nil, "error")
|
|
|> refresh_content(socket.assigns.workbench)
|
|
|
|
defp sidebar_create_callbacks do
|
|
%{
|
|
reload: &reload_shell/2,
|
|
refresh_content: &refresh_content/2,
|
|
open_sidebar: &open_sidebar_item/3,
|
|
append_output: &append_output_entry/5
|
|
}
|
|
end
|
|
|
|
defp open_sidebar_item(socket, params, intent) do
|
|
route_atom = sidebar_route_atom(Map.fetch!(params, "route"))
|
|
tab_id = tab_id_for_route(route_atom, Map.fetch!(params, "id"))
|
|
|
|
workbench =
|
|
Workbench.open_tab(
|
|
socket.assigns.workbench,
|
|
route_atom,
|
|
tab_id,
|
|
tab_intent(route_atom, intent)
|
|
)
|
|
|
|
tab_meta =
|
|
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
|
|
sidebar_item_id: Map.get(params, "id"),
|
|
title: Map.get(params, "title", ""),
|
|
subtitle: Map.get(params, "subtitle", "")
|
|
})
|
|
|
|
tab_meta = TabHelpers.sync_tab_meta(workbench, tab_meta)
|
|
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> refresh_layout(workbench)
|
|
|> push_url_state()
|
|
end
|
|
|
|
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
|
|
|
defp set_page_language(socket, language) do
|
|
codes =
|
|
Enum.map(
|
|
socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(),
|
|
& &1.code
|
|
)
|
|
|
|
normalized =
|
|
language
|
|
|> to_string()
|
|
|> String.trim()
|
|
|> case do
|
|
value -> if(value in codes, do: value, else: socket.assigns.page_language)
|
|
end
|
|
|
|
if normalized == socket.assigns.page_language do
|
|
socket
|
|
else
|
|
UILocale.put(normalized)
|
|
|
|
socket
|
|
|> assign(:page_language, normalized)
|
|
|> reload_shell(socket.assigns.workbench)
|
|
|> tap(&sync_menu_bar_locale/1)
|
|
end
|
|
end
|
|
|
|
defp activate_project(socket, project_id, title, message_fun) do
|
|
cond do
|
|
project_id == socket.assigns.projects.active_project_id ->
|
|
assign(socket, :project_menu_open, false)
|
|
|
|
true ->
|
|
case Projects.set_active_project(project_id) do
|
|
{:ok, project} ->
|
|
socket
|
|
|> assign(:project_menu_open, false)
|
|
|> assign(:sidebar_filters_by_view, %{})
|
|
|> append_output_entry(title, message_fun.(project))
|
|
|> reload_shell(Workbench.new())
|
|
|> push_url_state()
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> assign(:project_menu_open, false)
|
|
|> append_output_entry(title, inspect(reason), nil, "error")
|
|
end
|
|
end
|
|
end
|
|
|
|
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
|
|
entry = %{title: title, message: message, details: details, level: level}
|
|
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
|
|
assign(socket, :output_entries, entries)
|
|
end
|
|
|
|
defp handle_native_menu_action(socket, action) do
|
|
case BoundedAtoms.menu_action(action) do
|
|
nil -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error")
|
|
action_atom -> handle_menu_action(socket, action_atom)
|
|
end
|
|
end
|
|
|
|
defp handle_menu_action(socket, action) when is_atom(action) do
|
|
cond do
|
|
MapSet.member?(@layout_menu_actions, action) ->
|
|
refresh_layout(socket, MenuBar.execute(socket.assigns.workbench, action))
|
|
|
|
MapSet.member?(@sidebar_menu_actions, action) ->
|
|
workbench = MenuBar.execute(socket.assigns.workbench, action)
|
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
|
|
socket
|
|
|> assign(:tab_meta, tab_meta)
|
|
|> refresh_sidebar(workbench)
|
|
|> push_url_state()
|
|
|
|
MapSet.member?(@socket_menu_actions, action) ->
|
|
handle_socket_menu_action(socket, action)
|
|
|
|
MapSet.member?(@runtime_menu_actions, action) ->
|
|
push_event(socket, "menu-runtime-command", %{action: Atom.to_string(action)})
|
|
|
|
shell_command?(action) ->
|
|
apply_shell_command(socket, Atom.to_string(action))
|
|
|
|
true ->
|
|
append_output_entry(
|
|
socket,
|
|
"Menu",
|
|
"Unsupported shell command",
|
|
Atom.to_string(action),
|
|
"error"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
|
|
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
|
|
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
|
|
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
|
|
|
|
defp handle_socket_menu_action(socket, :quit) do
|
|
Shutdown.request_quit()
|
|
socket
|
|
end
|
|
|
|
defp handle_socket_menu_action(socket, :view_on_github) do
|
|
OS.launch_default_browser(ExternalLinks.github_url())
|
|
socket
|
|
end
|
|
|
|
defp handle_socket_menu_action(socket, :report_issue) do
|
|
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
|
socket
|
|
end
|
|
|
|
defp handle_socket_menu_action(socket, :about) do
|
|
append_output_entry(
|
|
socket,
|
|
"About",
|
|
"Blogging Desktop Server",
|
|
"Version #{Application.spec(:bds, :vsn) |> to_string()}",
|
|
"info"
|
|
)
|
|
end
|
|
|
|
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
|
|
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
|
|
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
|
|
send_update(MenuEditor, id: "menu-editor", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
|
|
send_update(TagsEditor, id: "tags-editor", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
|
|
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
|
|
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
|
|
socket
|
|
end
|
|
|
|
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
|
|
|
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
|
|
socket
|
|
end
|
|
|
|
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
|
|
|
defp apply_shell_command(socket, action, params \\ %{}),
|
|
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
|
|
|
|
defp apply_shell_command_result(socket, result),
|
|
do: ShellCommandRunner.apply_result(socket, result, shell_command_callbacks())
|
|
|
|
defp shell_command_callbacks do
|
|
%{
|
|
reload: &reload_shell/2,
|
|
refresh_content: &refresh_content/2,
|
|
append_output: &append_output_entry/5
|
|
}
|
|
end
|
|
|
|
defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action)
|
|
|
|
defp mac_ui? do
|
|
case Application.get_env(:bds, :shell_platform) do
|
|
nil -> match?({:unix, :darwin}, :os.type())
|
|
platform -> match?({:unix, :darwin}, platform)
|
|
end
|
|
end
|
|
|
|
defp overlay_callbacks,
|
|
do: %{
|
|
reload: &reload_shell/2,
|
|
refresh_content: &refresh_content/2,
|
|
append_output: &append_output_entry/5,
|
|
execute_sidebar_delete: fn socket, route, id ->
|
|
SidebarDelete.execute_delete(socket, route, id, sidebar_delete_callbacks())
|
|
end
|
|
}
|
|
|
|
defp sidebar_delete_callbacks,
|
|
do: %{
|
|
reload: &reload_shell/2,
|
|
refresh_content: &refresh_content/2,
|
|
append_output: &append_output_entry/5
|
|
}
|
|
|
|
defp bridges_callbacks,
|
|
do: %{
|
|
reload: &reload_shell/2,
|
|
refresh_layout: &refresh_layout/2,
|
|
refresh_sidebar: &refresh_sidebar/2,
|
|
refresh_content: &refresh_content/2,
|
|
append_output: &append_output_entry/5,
|
|
open_sidebar: &open_sidebar_item/3,
|
|
apply_shell_command: &apply_shell_command/3,
|
|
apply_shell_command_result: &apply_shell_command_result/2
|
|
}
|
|
|
|
defp sync_menu_bar_locale(socket) do
|
|
locale = socket.assigns.page_language
|
|
|
|
case Process.whereis(BDS.Desktop.MenuBar) do
|
|
nil -> :ok
|
|
pid -> send(pid, {:set_ui_locale, locale})
|
|
end
|
|
end
|
|
end
|