From 58a332c7c4f982349131824967377ed254c33f08 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 18:56:05 +0200 Subject: [PATCH] feat: added blog selection for existing blog --- lib/bds/desktop/folder_picker.ex | 35 ++++++++++++ lib/bds/desktop/router.ex | 9 +++ lib/bds/desktop/shell_controller.ex | 50 +++++++++++++++++ priv/i18n/locales/de.json | 2 + priv/i18n/locales/en.json | 2 + priv/i18n/locales/es.json | 2 + priv/i18n/locales/fr.json | 2 + priv/i18n/locales/it.json | 2 + priv/ui/app.css | 8 ++- priv/ui/app.js | 77 +++++++++++++++++++++++++- test/bds/desktop_test.exs | 86 +++++++++++++++++++++-------- test/bds/ui/shell_test.exs | 6 +- 12 files changed, 253 insertions(+), 28 deletions(-) create mode 100644 lib/bds/desktop/folder_picker.ex diff --git a/lib/bds/desktop/folder_picker.ex b/lib/bds/desktop/folder_picker.ex new file mode 100644 index 0000000..a8e1bad --- /dev/null +++ b/lib/bds/desktop/folder_picker.ex @@ -0,0 +1,35 @@ +defmodule BDS.Desktop.FolderPicker do + @moduledoc false + + def choose_directory(prompt) when is_binary(prompt) do + case :os.type() do + {:unix, :darwin} -> choose_directory_macos(prompt) + _other -> {:error, %{message: "Folder selection is only supported on macOS desktop"}} + end + end + + defp choose_directory_macos(prompt) do + script = "POSIX path of (choose folder with prompt \"#{escape_applescript(prompt)}\")" + + case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do + {output, 0} -> {:ok, String.trim(output)} + {output, _status} -> normalize_picker_failure(output) + end + end + + defp normalize_picker_failure(output) do + message = String.trim(output) + + if message == "" or String.contains?(String.downcase(message), "canceled") do + :cancel + else + {:error, %{message: message}} + end + end + + defp escape_applescript(value) do + value + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + end +end \ No newline at end of file diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index 918008e..6c177d6 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -51,6 +51,15 @@ defmodule BDS.Desktop.Router do |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.upsert_project_json(payload)) end + post "/api/project-folder" do + {:ok, body, conn} = Plug.Conn.read_body(conn) + payload = if body == "", do: %{}, else: Jason.decode!(body) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.choose_project_folder_json(payload)) + end + post "/api/commands" do {:ok, body, conn} = Plug.Conn.read_body(conn) payload = if body == "", do: %{}, else: Jason.decode!(body) diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex index 0a00d65..98d8f1d 100644 --- a/lib/bds/desktop/shell_controller.ex +++ b/lib/bds/desktop/shell_controller.ex @@ -28,6 +28,16 @@ defmodule BDS.Desktop.ShellController do end end + def choose_project_folder_json(payload \\ %{}) when is_map(payload) do + prompt = Map.get(payload, "prompt") || Map.get(payload, :prompt) || "Select existing blog folder" + + case folder_picker().choose_directory(prompt) do + {:ok, path} -> Jason.encode!(project_folder_payload(path)) + :cancel -> Jason.encode!(%{status: "cancel"}) + {:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)}) + end + end + def command_json(payload) when is_map(payload) do action = Map.get(payload, "action") || Map.get(payload, :action) params = Map.get(payload, "params") || Map.get(payload, :params) || %{} @@ -47,6 +57,7 @@ defmodule BDS.Desktop.ShellController do {:create, %{ name: String.trim(Map.get(payload, "name") || Map.get(payload, :name)), + description: blank_to_nil(Map.get(payload, "description") || Map.get(payload, :description)), data_path: blank_to_nil(Map.get(payload, "data_path") || Map.get(payload, :data_path)) }} @@ -84,6 +95,45 @@ defmodule BDS.Desktop.ShellController do %{id: project.id, name: project.name, slug: project.slug, data_path: project.data_path, is_active: project.is_active} end + defp project_folder_payload(path) do + normalized_path = Path.expand(path) + project_metadata = read_project_metadata(normalized_path) + existing_project = find_project_by_data_path(normalized_path) + + %{ + status: "ok", + path: normalized_path, + name: Map.get(project_metadata, "name") || Path.basename(normalized_path), + description: Map.get(project_metadata, "description"), + existing_project_id: existing_project && existing_project.id + } + end + + defp read_project_metadata(path) do + project_json_path = Path.join([path, "meta", "project.json"]) + + case File.read(project_json_path) do + {:ok, contents} -> Jason.decode!(contents) + {:error, :enoent} -> %{} + end + end + + defp find_project_by_data_path(path) do + normalized_path = Path.expand(path) + + BDS.Projects.list_projects() + |> Enum.find(fn project -> + case project.data_path do + value when is_binary(value) -> Path.expand(value) == normalized_path + _other -> false + end + end) + end + + defp folder_picker do + Application.get_env(:bds, :desktop, [])[:folder_picker] || BDS.Desktop.FolderPicker + end + defp default_project_snapshot do %{ active_project_id: "default", diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 212dcec..8601520 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -111,6 +111,7 @@ "Offline Gate": "Offline-Sperre", "Open": "Öffnen", "Open Data Folder": "Datenordner öffnen", + "Open Existing Blog": "Bestehenden Blog öffnen", "Open in Browser": "Im Browser öffnen", "Opened URL": "URL geöffnet", "Orphan Files": "Verwaiste Dateien", @@ -133,6 +134,7 @@ "Script": "Skript", "Scripts": "Skripte", "Select Project": "Projekt auswählen", + "Select existing blog folder": "Bestehenden Blog-Ordner auswählen", "Settings": "Einstellungen", "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Seitenleiste, Tabs, Panel und Assistentenbereiche sind als DOM-Regionen inspizierbar", "Site Validation": "Website-Validierung", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 7e13be0..60be6e0 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -111,6 +111,7 @@ "Offline Gate": "Offline Gate", "Open": "Open", "Open Data Folder": "Open Data Folder", + "Open Existing Blog": "Open Existing Blog", "Open in Browser": "Open in Browser", "Opened URL": "Opened URL", "Orphan Files": "Orphan Files", @@ -133,6 +134,7 @@ "Script": "Script", "Scripts": "Scripts", "Select Project": "Select Project", + "Select existing blog folder": "Select existing blog folder", "Settings": "Settings", "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions", "Site Validation": "Site Validation", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index 710e2b1..9ae9918 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -111,6 +111,7 @@ "Offline Gate": "Bloqueo sin conexión", "Open": "Abrir", "Open Data Folder": "Abrir carpeta de datos", + "Open Existing Blog": "Abrir blog existente", "Open in Browser": "Abrir en el navegador", "Opened URL": "URL abierta", "Orphan Files": "Archivos huérfanos", @@ -133,6 +134,7 @@ "Script": "Script", "Scripts": "Scripts", "Select Project": "Seleccionar proyecto", + "Select existing blog folder": "Seleccionar carpeta de blog existente", "Settings": "Configuración", "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "La barra lateral, las pestañas, el panel y el asistente son regiones DOM inspeccionables", "Site Validation": "Validación del sitio", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index d8824f8..d753d86 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -111,6 +111,7 @@ "Offline Gate": "Verrou hors ligne", "Open": "Ouvrir", "Open Data Folder": "Ouvrir le dossier de données", + "Open Existing Blog": "Ouvrir un blog existant", "Open in Browser": "Ouvrir dans le navigateur", "Opened URL": "URL ouverte", "Orphan Files": "Fichiers orphelins", @@ -133,6 +134,7 @@ "Script": "Script", "Scripts": "Scripts", "Select Project": "Sélectionner un projet", + "Select existing blog folder": "Sélectionner le dossier d’un blog existant", "Settings": "Paramètres", "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "La barre latérale, les onglets, le panneau et l’assistant sont des régions DOM inspectables", "Site Validation": "Validation du site", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index aa0387c..c4beb80 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -111,6 +111,7 @@ "Offline Gate": "Blocco offline", "Open": "Apri", "Open Data Folder": "Apri cartella dati", + "Open Existing Blog": "Apri blog esistente", "Open in Browser": "Apri nel browser", "Opened URL": "URL aperto", "Orphan Files": "File orfani", @@ -133,6 +134,7 @@ "Script": "Script", "Scripts": "Script", "Select Project": "Seleziona progetto", + "Select existing blog folder": "Seleziona la cartella di un blog esistente", "Settings": "Impostazioni", "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Barra laterale, schede, pannello e assistente sono regioni DOM ispezionabili", "Site Validation": "Validazione sito", diff --git a/priv/ui/app.css b/priv/ui/app.css index 5b129d5..c1ac0be 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -919,9 +919,12 @@ button { .project-dropdown-footer { padding: 8px; border-top: 1px solid rgba(255, 255, 255, 0.12); + display: grid; + gap: 6px; } -.create-project-btn { +.create-project-btn, +.existing-project-btn { display: flex; align-items: center; justify-content: center; @@ -936,7 +939,8 @@ button { cursor: pointer; } -.create-project-btn:hover { +.create-project-btn:hover, +.existing-project-btn:hover { background-color: rgba(255, 255, 255, 0.18); } diff --git a/priv/ui/app.js b/priv/ui/app.js index 6d6ce35..7f442bf 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -483,6 +483,12 @@ function bindEvents() { }; }); + root.querySelectorAll("[data-project-import]").forEach((button) => { + button.onclick = () => { + void importExistingProject(); + }; + }); + root.querySelectorAll("[data-command='set-ui-language']").forEach((select) => { select.onchange = (event) => { setUiLanguage(event.target.value); @@ -677,8 +683,42 @@ async function fetchProjects() { } } -async function createProject() { - const name = window.prompt(t("New project name"), t("New Project")); +async function chooseProjectFolder() { + try { + const response = await fetch("/api/project-folder", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ prompt: t("Select existing blog folder") }), + }); + + const payload = await response.json(); + + if (!response.ok || payload.status === "error") { + appendOutputEntry(t("Open Existing Blog"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status })); + setPanelTab("output"); + render(); + return null; + } + + if (payload.status === "cancel") { + return null; + } + + return payload; + } catch (error) { + appendOutputEntry(t("Open Existing Blog"), error?.message || String(error)); + setPanelTab("output"); + render(); + return null; + } +} + +async function createProject(options = {}) { + const suggestedName = options.name ? String(options.name).trim() : ""; + const name = suggestedName || window.prompt(t("New project name"), t("New Project")); if (!name || !name.trim()) { return; } @@ -692,7 +732,11 @@ async function createProject() { Accept: "application/json", "Content-Type": "application/json", }, - body: JSON.stringify({ name: name.trim() }), + body: JSON.stringify({ + name: name.trim(), + description: options.description, + data_path: options.dataPath, + }), }); const payload = await response.json(); @@ -714,6 +758,27 @@ async function createProject() { } } +async function importExistingProject() { + closeProjectMenu(); + + const selection = await chooseProjectFolder(); + + if (!selection) { + return; + } + + if (selection.existing_project_id) { + await selectProject(selection.existing_project_id); + return; + } + + await createProject({ + name: selection.name, + description: selection.description, + dataPath: selection.path, + }); +} + async function selectProject(projectId) { if (!projectId || projectId === state.projects.active_project_id) { closeProjectMenu(); @@ -1408,6 +1473,12 @@ function renderProjectDropdown() { ${state.projects.projects.map((project) => renderProjectItem(project)).join("")}