source import export

0dbe0b5 · gmorell · 2025-08-08 22:04

14 files +188 -4

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