neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
source import export
0dbe0b5 · gmorell · 2025-08-08 22:04
Files changed
modified
apps/room_sanctum/assets/js/app.js
+33
−0
@@ -294,6 +294,39 @@ topbar.config({barColors: {0: "#b57979"}, shadowColor: "rgba(0, 0, 0, .3)"})
| 294 | 294 | window.addEventListener("phx:page-loading-start", info => topbar.show()) |
| 295 | 295 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) |
| 296 | 296 | |
| 297 | +// Handle file downloads | |
| 298 | +window.addEventListener("phx:download", (event) => { | |
| 299 | + const { data, filename, mime_type } = event.detail; | |
| 300 | + const blob = new Blob([data], { type: mime_type }); | |
| 301 | + const url = window.URL.createObjectURL(blob); | |
| 302 | + const a = document.createElement("a"); | |
| 303 | + a.href = url; | |
| 304 | + a.download = filename; | |
| 305 | + document.body.appendChild(a); | |
| 306 | + a.click(); | |
| 307 | + window.URL.revokeObjectURL(url); | |
| 308 | + document.body.removeChild(a); | |
| 309 | +}); | |
| 310 | + | |
| 311 | +// Handle file uploads | |
| 312 | +window.addEventListener("phx:upload_file", (event) => { | |
| 313 | + const input = document.createElement("input"); | |
| 314 | + input.type = "file"; | |
| 315 | + input.accept = ".json"; | |
| 316 | + input.addEventListener("change", (e) => { | |
| 317 | + const file = e.target.files[0]; | |
| 318 | + if (file) { | |
| 319 | + const reader = new FileReader(); | |
| 320 | + reader.onload = (e) => { | |
| 321 | + const fileData = e.target.result; | |
| 322 | + liveSocket.pushEvent("process-import", { file_data: fileData }); | |
| 323 | + }; | |
| 324 | + reader.readAsText(file); | |
| 325 | + } | |
| 326 | + }); | |
| 327 | + input.click(); | |
| 328 | +}); | |
| 329 | + | |
| 297 | 330 | // connect if there are any LiveViews on the page |
| 298 | 331 | liveSocket.connect() |
| 299 | 332 |
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+1
−1
@@ -2070,7 +2070,7 @@ defmodule RoomSanctum.Storage do
| 2070 | 2070 | from st in StationStatus, |
| 2071 | 2071 | where: st.source_id == ^source_id and st.station_id == ^stringify(stop_id), |
| 2072 | 2072 | left_join: si in StationInfo, |
| 2073 | − on: si.station_id == st.station_id, | |
| 2073 | + on: si.station_id == st.station_id and si.source_id == ^source_id, | |
| 2074 | 2074 | left_join: eb in EbikesAtStations, |
| 2075 | 2075 | on: eb.station_id == st.station_id, |
| 2076 | 2076 | # left_join: t in GBFSAlert, |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/index.ex
+152
−2
@@ -6,11 +6,18 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 6 | 6 | |
| 7 | 7 | @impl true |
| 8 | 8 | def mount(_params, _session, socket) do |
| 9 | + sources = list_cfg_sources(socket.assigns.current_user.id) | |
| 10 | + | |
| 9 | 11 | {:ok, |
| 10 | 12 | socket |
| 11 | 13 | |> assign(:show_info, false) |
| 12 | 14 | |> assign(:tint, nil) |
| 13 | − |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id)) | |
| 15 | + |> assign(:selected_sources, MapSet.new()) | |
| 16 | + |> assign(:export_json, nil) | |
| 17 | + |> assign(:show_export_modal, false) | |
| 18 | + |> assign(:cfg_sources_ids, sources |> Enum.map(fn x -> x.id end)) | |
| 19 | + |> stream(:cfg_sources, sources) | |
| 20 | + | |
| 14 | 21 | } |
| 15 | 22 | end |
| 16 | 23 |
@@ -31,6 +38,12 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 31 | 38 | |> assign(:source, %Source{}) |
| 32 | 39 | end |
| 33 | 40 | |
| 41 | + defp apply_action(socket, :import, _params) do | |
| 42 | + socket | |
| 43 | + |> assign(:page_title, "Import Offerings") | |
| 44 | + |> assign(:source, nil) | |
| 45 | + end | |
| 46 | + | |
| 34 | 47 | defp apply_action(socket, :index, _params) do |
| 35 | 48 | socket |
| 36 | 49 | |> assign(:page_title, "Available Offerings") |
@@ -42,6 +55,14 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 42 | 55 | {:noreply, stream_insert(socket, :cfg_sources, source)} |
| 43 | 56 | end |
| 44 | 57 | |
| 58 | + @impl true | |
| 59 | + def handle_info({RoomSanctumWeb.SourceLive.ImportComponent, {:sources_imported, count}}, socket) do | |
| 60 | + {:noreply, | |
| 61 | + socket | |
| 62 | + |> put_flash(:info, "Successfully imported #{count} sources") | |
| 63 | + |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id), reset: true)} | |
| 64 | + end | |
| 65 | + | |
| 45 | 66 | @impl true |
| 46 | 67 | def handle_event("delete", %{"id" => id}, socket) do |
| 47 | 68 | source = Configuration.get_source!(id) |
@@ -62,7 +83,6 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 62 | 83 | { |
| 63 | 84 | :noreply, |
| 64 | 85 | socket |
| 65 | − |> stream_delete(:cfg_sources, src_og) | |
| 66 | 86 | |> stream_insert(:cfg_sources, src, at: -1) |
| 67 | 87 | |> put_flash(:info, ["Toggled ", src.name, " ", raw(icon), " Successfully"]) |
| 68 | 88 | } |
@@ -78,7 +98,83 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 78 | 98 | true -> {:noreply, socket |> assign(:tint, nil) |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id), reset: true)} |
| 79 | 99 | false -> {:noreply, socket |> assign(:tint, tint) |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id, tint), reset: true)} |
| 80 | 100 | end |
| 101 | + end | |
| 81 | 102 | |
| 103 | + def handle_event("toggle-source-selection", %{"id" => id} = params, socket) do | |
| 104 | + source_id = String.to_integer(id) | |
| 105 | + IO.inspect(params, label: "checkbox params") | |
| 106 | + # Check if the checkbox is checked by looking for the "value" key | |
| 107 | + is_checked = Map.has_key?(params, "value") | |
| 108 | + | |
| 109 | + selected_sources = case is_checked do | |
| 110 | + true -> MapSet.put(socket.assigns.selected_sources, source_id) | |
| 111 | + false -> MapSet.delete(socket.assigns.selected_sources, source_id) | |
| 112 | + end | |
| 113 | + IO.inspect(selected_sources, label: "updated selection") | |
| 114 | + {:noreply, assign(socket, :selected_sources, selected_sources)} | |
| 115 | + end | |
| 116 | + | |
| 117 | + def handle_event("toggle-all-selection", _params, socket) do | |
| 118 | + # Get all source IDs from the stream | |
| 119 | + all_source_ids = | |
| 120 | + socket.assigns.cfg_sources_ids | |
| 121 | + | |
| 122 | + IO.inspect(all_source_ids) | |
| 123 | + IO.inspect(MapSet.size(socket.assigns.selected_sources) ) | |
| 124 | + | |
| 125 | + selected_sources = case MapSet.size(socket.assigns.selected_sources) do | |
| 126 | + 0 -> MapSet.new(all_source_ids ) # Select all | |
| 127 | + _ -> MapSet.new() # Deselect all | |
| 128 | + end | |
| 129 | + | |
| 130 | + IO.inspect(selected_sources, label: "SELSEOURCE") | |
| 131 | + | |
| 132 | + {:noreply, assign(socket, :selected_sources, selected_sources)} | |
| 133 | + end | |
| 134 | + | |
| 135 | + def handle_event("export-sources", _params, socket) do | |
| 136 | + selected_ids = MapSet.to_list(socket.assigns.selected_sources) | |
| 137 | + sources = Enum.map(selected_ids, &Configuration.get_source!/1) | |
| 138 | + | |
| 139 | + # Clean sources by removing unwanted fields | |
| 140 | + cleaned_sources = Enum.map(sources, &clean_source_for_export/1) | |
| 141 | + | |
| 142 | + export_data = %{ | |
| 143 | + exported_at: DateTime.utc_now(), | |
| 144 | + sources: cleaned_sources | |
| 145 | + } | |
| 146 | + | |
| 147 | + json_data = Poison.encode!(export_data, pretty: true) | |
| 148 | + | |
| 149 | + {:noreply, | |
| 150 | + socket | |
| 151 | + |> assign(:export_json, json_data) | |
| 152 | + |> assign(:show_export_modal, true)} | |
| 153 | + end | |
| 154 | + | |
| 155 | + def handle_event("close-export-modal", _params, socket) do | |
| 156 | + {:noreply, | |
| 157 | + socket | |
| 158 | + |> assign(:export_json, nil) | |
| 159 | + |> assign(:show_export_modal, false)} | |
| 160 | + end | |
| 161 | + | |
| 162 | + def handle_event("import-sources", _params, socket) do | |
| 163 | + {:noreply, push_event(socket, "upload_file", %{})} | |
| 164 | + end | |
| 165 | + | |
| 166 | + def handle_event("process-import", %{"file_data" => file_data}, socket) do | |
| 167 | + case Poison.decode(file_data) do | |
| 168 | + {:ok, %{"sources" => sources_data}} -> | |
| 169 | + imported_count = import_sources(sources_data, socket.assigns.current_user.id) | |
| 170 | + {:noreply, | |
| 171 | + socket | |
| 172 | + |> put_flash(:info, "Successfully imported #{imported_count} sources") | |
| 173 | + |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id), reset: true)} | |
| 174 | + | |
| 175 | + {:error, _} -> | |
| 176 | + {:noreply, put_flash(socket, :error, "Invalid import file format")} | |
| 177 | + end | |
| 82 | 178 | end |
| 83 | 179 | |
| 84 | 180 | defp list_cfg_sources(uid) do |
@@ -89,6 +185,52 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 89 | 185 | Configuration.list_cfg_sources({:user, uid}) |> Enum.filter(fn s -> s.meta.tint == tint end) |
| 90 | 186 | end |
| 91 | 187 | |
| 188 | + defp import_sources(sources_data, user_id) do | |
| 189 | + sources_data | |
| 190 | + |> Enum.reduce(0, fn source_data, count -> | |
| 191 | + # Remove id and timestamps to let database generate new ones | |
| 192 | + source_attrs = | |
| 193 | + source_data | |
| 194 | + |> Map.delete("id") | |
| 195 | + |> Map.delete("inserted_at") | |
| 196 | + |> Map.delete("updated_at") | |
| 197 | + |> Map.put("user_id", user_id) | |
| 198 | + |> atomize_keys() | |
| 199 | + | |
| 200 | + case Configuration.create_source(source_attrs) do | |
| 201 | + {:ok, _source} -> count + 1 | |
| 202 | + {:error, _changeset} -> count | |
| 203 | + end | |
| 204 | + end) | |
| 205 | + end | |
| 206 | + | |
| 207 | + defp atomize_keys(map) when is_map(map) do | |
| 208 | + map | |
| 209 | + |> Enum.map(fn {k, v} -> | |
| 210 | + key = if is_binary(k), do: String.to_existing_atom(k), else: k | |
| 211 | + {key, atomize_keys(v)} | |
| 212 | + end) | |
| 213 | + |> Enum.into(%{}) | |
| 214 | + rescue | |
| 215 | + ArgumentError -> map # If atom doesn't exist, keep as string | |
| 216 | + end | |
| 217 | + | |
| 218 | + defp atomize_keys(value), do: value | |
| 219 | + | |
| 220 | + defp clean_source_for_export(source) do | |
| 221 | + # Add the type field to the config map for import compatibility | |
| 222 | + updated_config = | |
| 223 | + case source.config do | |
| 224 | + %{} = config when is_map(config) -> Map.put(config, :__type__, source.type) | |
| 225 | + config -> config | |
| 226 | + end | |
| 227 | + | |
| 228 | + source | |
| 229 | + |> Map.drop([:__meta__, :mailboxes, :webhooks, :user]) | |
| 230 | + |> Map.drop(["__meta__", "mailboxes", "webhooks", "user"]) | |
| 231 | + |> Map.put(:config, updated_config) | |
| 232 | + end | |
| 233 | + | |
| 92 | 234 | defp get_icon(source_type) do |
| 93 | 235 | case source_type do |
| 94 | 236 | :calendar -> |
@@ -128,4 +270,12 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 128 | 270 | "fa-envelopes-bulk" |
| 129 | 271 | end |
| 130 | 272 | end |
| 273 | + | |
| 274 | + def all_selected(list, selected) do | |
| 275 | + cond do | |
| 276 | + selected |> MapSet.size == 0 -> :false | |
| 277 | + list.inserts |> length == selected |> MapSet.size -> "true" | |
| 278 | + true -> :false | |
| 279 | + end | |
| 280 | + end | |
| 131 | 281 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/router.ex
+1
−0
@@ -55,6 +55,7 @@ defmodule RoomSanctumWeb.Router do
| 55 | 55 | |
| 56 | 56 | live "/cfg/offerings", SourceLive.Index, :index |
| 57 | 57 | live "/cfg/offerings/new", SourceLive.Index, :new |
| 58 | + live "/cfg/offerings/import", SourceLive.Index, :import | |
| 58 | 59 | live "/cfg/offerings/:id/edit", SourceLive.Index, :edit |
| 59 | 60 | live "/cfg/offerings/:id", SourceLive.Show, :show |
| 60 | 61 | live "/cfg/offerings/:id/show/edit", SourceLive.Show, :edit |
modified
docker-compose.yml
+1
−1
@@ -27,6 +27,6 @@ services:
| 27 | 27 | - RABBITMQ_DEFAULT_PASS=hermes |
| 28 | 28 | volumes: |
| 29 | 29 | - ./rabbit/rabbit.conf:/etc/rabbitmq/rabbitmq.conf |
| 30 | − - ./rabbit/enabled_plugins:/etc/rabbitmq/enabled_plugins | |
| 30 | +# - ./rabbit/enabled_plugins:/etc/rabbitmq/enabled_plugins | |
| 31 | 31 | volumes: |
| 32 | 32 | pgdata: {} |
Parents: ac18dca