RunicVTT Open Source Virtual Tabletop for TTRPG using P2P
Loading...
Searching...
No Matches
AssetIO.h
Go to the documentation of this file.
1// include/assets/AssetIO.h
2#pragma once
3#define WIN32_LEAN_AND_MEAN
4#define NOMINMAX
5#include <Windows.h>
6#include <ShObjIdl.h> // IFileOpenDialog
7#include <Urlmon.h> // URLDownloadToFileW
8#include <filesystem>
9#include <string>
10#include <optional>
11#include <vector>
12#include <fstream>
13#include <cctype>
14#include <algorithm>
15#include <system_error>
16
17#include "PathManager.h" // must provide getMarkersPath(), getMapsPath()
18#include "imgui.h"
19#include "ImGuiToaster.h"
20
21namespace AssetIO
22{
23
24 enum class AssetKind
25 {
26 Marker,
27 Map
28 };
29
30 inline const std::filesystem::path& assetsDir(AssetKind kind)
31 {
32 static std::filesystem::path markers = PathManager::getMarkersPath();
33 static std::filesystem::path maps = PathManager::getMapsPath();
34 return (kind == AssetKind::Marker) ? markers : maps;
35 }
36
37 struct ComInit
38 {
39 HRESULT hr{E_FAIL};
41 {
42 hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
43 }
45 {
46 if (SUCCEEDED(hr))
47 CoUninitialize();
48 }
49 bool ok() const
50 {
51 return SUCCEEDED(hr);
52 }
53 };
54
55 // Lowercase + replace spaces with '_' + strip weird chars
56 inline std::string slugify(const std::string& s)
57 {
58 std::string out;
59 out.reserve(s.size());
60 for (unsigned char ch : s)
61 {
62 char c = static_cast<char>(std::tolower(ch));
63 if (std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' || c == '-')
64 {
65 out.push_back(c);
66 }
67 else if (std::isspace(static_cast<unsigned char>(c)))
68 {
69 out.push_back('_');
70 } // else drop
71 }
72 if (out.empty())
73 out = "asset";
74 return out;
75 }
76
77 // Ensure unique filename inside destDir (keeps extension)
78 inline std::filesystem::path uniqueName(const std::filesystem::path& destDir,
79 const std::string& baseFile)
80 {
81 std::filesystem::path name = destDir / baseFile;
82 if (!std::filesystem::exists(name))
83 return name;
84
85 auto stem = std::filesystem::path(baseFile).stem().string();
86 auto ext = std::filesystem::path(baseFile).extension().string();
87 for (int i = 1; i < 100000; ++i)
88 {
89 auto trial = destDir / (stem + " (" + std::to_string(i) + ")" + ext);
90 if (!std::filesystem::exists(trial))
91 return trial;
92 }
93 // fallback improbable
94 return destDir / (stem + "_dup" + ext);
95 }
96
97 // Windows modern file picker (images)
98 inline std::optional<std::filesystem::path> pickImageFileWin32()
99 {
100 ComInit com;
101 if (!com.ok())
102 return std::nullopt;
103
104 IFileOpenDialog* pDlg = nullptr;
105 HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER,
106 IID_PPV_ARGS(&pDlg));
107 if (FAILED(hr) || !pDlg)
108 return std::nullopt;
109
110 // Filter for common image types; stb_image supports png/jpg/bmp/tga/gif/psd/pic/hdr
111 COMDLG_FILTERSPEC filters[] = {
112 {L"Images (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp;*.tga)", L"*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp;*.tga"},
113 {L"All files (*.*)", L"*.*"}};
114 pDlg->SetFileTypes(2, filters);
115 pDlg->SetFileTypeIndex(1);
116 pDlg->SetOptions(FOS_PATHMUSTEXIST | FOS_FILEMUSTEXIST);
117
118 std::optional<std::filesystem::path> result;
119
120 hr = pDlg->Show(nullptr);
121 if (SUCCEEDED(hr))
122 {
123 IShellItem* pItem = nullptr;
124 if (SUCCEEDED(pDlg->GetResult(&pItem)) && pItem)
125 {
126 PWSTR pszFilePath = nullptr;
127 if (SUCCEEDED(pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath)) && pszFilePath)
128 {
129 result = std::filesystem::path(pszFilePath);
130 CoTaskMemFree(pszFilePath);
131 }
132 pItem->Release();
133 }
134 }
135 pDlg->Release();
136 return result;
137 }
138
139 // Copy a source file into app assets folder for given kind
140 inline bool importFromPath(AssetKind kind, const std::filesystem::path& srcPath,
141 std::filesystem::path* outDst = nullptr,
142 std::string* outError = nullptr)
143 {
144 std::error_code ec;
145 if (!std::filesystem::exists(srcPath))
146 {
147 if (outError)
148 *outError = "Source does not exist";
149 return false;
150 }
151 auto destRoot = assetsDir(kind);
152 std::filesystem::create_directories(destRoot, ec);
153 ec.clear();
154
155 // Build a nice destination name
156 auto filename = srcPath.filename().string();
157 auto ext = std::filesystem::path(filename).extension().string();
158 auto stem = std::filesystem::path(filename).stem().string();
159 std::string base = slugify(stem) + ext;
160 auto dst = uniqueName(destRoot, base);
161
162 std::filesystem::copy_file(srcPath, dst,
163 std::filesystem::copy_options::overwrite_existing, ec);
164 if (ec)
165 {
166 if (outError)
167 *outError = "Copy failed: " + ec.message();
168 return false;
169 }
170 if (outDst)
171 *outDst = dst;
172 return true;
173 }
174
175 // File picker → import into kind
176 inline bool importFromPicker(AssetKind kind,
177 std::filesystem::path* outDst = nullptr,
178 std::string* outError = nullptr)
179 {
180 auto p = pickImageFileWin32();
181 if (!p)
182 {
183 if (outError)
184 *outError = "User cancelled";
185 return false;
186 }
187 return importFromPath(kind, *p, outDst, outError);
188 }
189
190 // NEW: pick multiple files
191 inline std::vector<std::filesystem::path> pickImageFilesWin32()
192 {
193 std::vector<std::filesystem::path> out;
194 ComInit com;
195 if (!com.ok())
196 return out;
197
198 IFileOpenDialog* pDlg = nullptr;
199 if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pDlg))) || !pDlg)
200 return out;
201
202 COMDLG_FILTERSPEC filters[] = {
203 {L"Images (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp;*.tga)", L"*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp;*.tga"},
204 {L"All files (*.*)", L"*.*"}};
205 pDlg->SetFileTypes(2, filters);
206 pDlg->SetFileTypeIndex(1);
207
208 DWORD opts = FOS_PATHMUSTEXIST | FOS_FILEMUSTEXIST | FOS_ALLOWMULTISELECT; // <- key change
209 pDlg->SetOptions(opts);
210
211 if (SUCCEEDED(pDlg->Show(nullptr)))
212 {
213 IShellItemArray* pArray = nullptr;
214 if (SUCCEEDED(pDlg->GetResults(&pArray)) && pArray)
215 {
216 DWORD count = 0;
217 pArray->GetCount(&count);
218 for (DWORD i = 0; i < count; ++i)
219 {
220 IShellItem* pItem = nullptr;
221 if (SUCCEEDED(pArray->GetItemAt(i, &pItem)) && pItem)
222 {
223 PWSTR psz = nullptr;
224 if (SUCCEEDED(pItem->GetDisplayName(SIGDN_FILESYSPATH, &psz)) && psz)
225 {
226 out.emplace_back(psz);
227 CoTaskMemFree(psz);
228 }
229 pItem->Release();
230 }
231 }
232 pArray->Release();
233 }
234 }
235 pDlg->Release();
236 return out;
237 }
238
239 // NEW: import many picked files
241 std::vector<std::filesystem::path>* outDsts = nullptr,
242 std::string* outError = nullptr)
243 {
244 auto files = pickImageFilesWin32();
245 if (files.empty())
246 {
247 if (outError)
248 *outError = "User cancelled";
249 return false;
250 }
251
252 size_t ok = 0, fail = 0;
253 std::vector<std::filesystem::path> dsts_local;
254 for (auto& f : files)
255 {
256 std::filesystem::path dst;
257 std::string err;
258 if (importFromPath(kind, f, &dst, &err))
259 {
260 ++ok;
261 if (outDsts)
262 outDsts->push_back(dst);
263 else
264 dsts_local.push_back(dst);
265 }
266 else
267 {
268 ++fail;
269 }
270 }
271
272 if (ok == 0)
273 {
274 if (outError)
275 *outError = "All imports failed";
276 return false;
277 }
278 if (fail > 0 && outError)
279 {
280 *outError = "Imported " + std::to_string(ok) + " file(s), " + std::to_string(fail) + " failed";
281 }
282 return true;
283 }
284 // Download a URL directly into assets dir (uses URLMon; supports http/https)
285 inline bool importFromUrl(AssetKind kind, const std::wstring& urlW,
286 std::filesystem::path* outDst = nullptr,
287 std::string* outError = nullptr)
288 {
289 // Guess filename from URL path
290 std::wstring fnameW = L"asset";
291 auto slash = urlW.find_last_of(L"/\\");
292 if (slash != std::wstring::npos && slash + 1 < urlW.size())
293 {
294 fnameW = urlW.substr(slash + 1);
295 if (fnameW.empty())
296 fnameW = L"asset";
297 }
298 // sanitize/slugify
299 std::string fnameUtf8(fnameW.begin(), fnameW.end());
300 auto ext = std::filesystem::path(fnameUtf8).extension().string();
301 auto stem = std::filesystem::path(fnameUtf8).stem().string();
302 std::string base = slugify(stem) + ext;
303
304 auto destRoot = assetsDir(kind);
305 std::error_code ec;
306 std::filesystem::create_directories(destRoot, ec);
307 ec.clear();
308
309 auto dst = uniqueName(destRoot, base);
310 std::wstring dstW = dst.wstring();
311
312 HRESULT hr = URLDownloadToFileW(nullptr, urlW.c_str(), dstW.c_str(), 0, nullptr);
313 if (FAILED(hr))
314 {
315 if (outError)
316 *outError = "Download failed (HRESULT " + std::to_string(hr) + ")";
317 return false;
318 }
319 if (outDst)
320 *outDst = dst;
321 return true;
322 }
323
324 // List assets for UI (absolute paths)
325 inline std::vector<std::filesystem::path> listAssets(AssetKind kind)
326 {
327 std::vector<std::filesystem::path> v;
328 auto root = assetsDir(kind);
329 std::error_code ec;
330 if (!std::filesystem::exists(root))
331 return v;
332 for (auto& e : std::filesystem::directory_iterator(root, ec))
333 {
334 if (ec)
335 break;
336 if (e.is_regular_file())
337 v.push_back(e.path());
338 }
339 std::sort(v.begin(), v.end());
340 return v;
341 }
342
343 inline bool deleteAsset(AssetKind kind, const std::filesystem::path& file,
344 std::string* outError = nullptr)
345 {
346 std::error_code ec;
347
348 // Resolve and validate root
349 auto root = std::filesystem::weakly_canonical(assetsDir(kind), ec);
350 if (ec || root.empty() || !std::filesystem::exists(root) || !std::filesystem::is_directory(root))
351 {
352 if (outError)
353 *outError = "Invalid assets root";
354 return false;
355 }
356
357 // Resolve the target (weakly_canonical lets non-existing leaf still resolve)
358 auto target = std::filesystem::weakly_canonical(file, ec);
359 if (ec || target.empty())
360 {
361 if (outError)
362 *outError = "Path canonicalization failed";
363 return false;
364 }
365
366 // Safety: target must be under root (and on same root/drive)
367 // lexically_relative returns a path that does not start with ".." if target is inside root.
368 auto rel = target.lexically_relative(root);
369 if (rel.empty() || rel.native().starts_with(L"..") || rel.is_absolute())
370 {
371 if (outError)
372 *outError = "Refusing to delete outside assets dir";
373 return false;
374 }
375
376 // Validate file
377 if (!std::filesystem::exists(target))
378 {
379 if (outError)
380 *outError = "File not found";
381 return false;
382 }
383 if (!std::filesystem::is_regular_file(target))
384 {
385 if (outError)
386 *outError = "Not a file";
387 return false;
388 }
389
390 // Delete
391 std::filesystem::remove(target, ec);
392 if (ec)
393 {
394 if (outError)
395 *outError = "Delete failed: " + ec.message();
396 return false;
397 }
398 return true;
399 }
400
401 inline void openDeleteAssetPopUp(std::weak_ptr<ImGuiToaster> toaster_)
402 {
403 ImGui::SetNextWindowSize(ImVec2(640, 480), ImGuiCond_Appearing);
404 ImGui::SetNextWindowSizeConstraints(ImVec2(400, 300), ImVec2(FLT_MAX, FLT_MAX));
405 if (ImGui::BeginPopupModal("DeleteAssets", nullptr))
406 {
407 static int tab = 0;
408 ImGui::RadioButton("Markers", &tab, 0);
409 ImGui::SameLine();
410 ImGui::RadioButton("Maps", &tab, 1);
411 auto kind = (tab == 0) ? AssetKind::Marker : AssetKind::Map;
412
413 auto assets = listAssets(kind);
414 ImGui::Separator();
415 ImGui::BeginChild("assets_scroll", ImVec2(500, 300), true);
416 bool deletedThisFrame = false;
417 for (auto& p : assets)
418 {
419 auto fname = p.filename().string();
420 ImGui::TextUnformatted(fname.c_str());
421 ImGui::SameLine();
422 ImGui::PushID(fname.c_str());
423 if (ImGui::SmallButton(("Delete##" + fname).c_str()))
424 {
425 std::string err;
426 if (!AssetIO::deleteAsset(kind, p, &err))
427 {
428 std::cerr << "Delete failed: " << err << "\n";
429 if (auto t = toaster_.lock(); t)
430 t->Push(ImGuiToaster::Level::Error, "Delete failed: " + err);
431 }
432 else
433 {
434 deletedThisFrame = true;
435 if (auto t = toaster_.lock(); t)
436 t->Push(ImGuiToaster::Level::Good, "Image Deleted Successfully!!");
437 }
438 }
439 ImGui::PopID();
440 if (deletedThisFrame)
441 break;
442 }
443 ImGui::EndChild();
444 ImGui::Separator();
445 if (ImGui::Button("Close"))
446 ImGui::CloseCurrentPopup();
447 ImGui::EndPopup();
448 }
449 }
450 //#TODO - INCOMPLETE EXTRA CRED
451 inline void openUrlAssetPopUp(std::weak_ptr<ImGuiToaster> toaster_)
452 {
453
454 if (ImGui::BeginPopupModal("UrlAssets", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
455 {
456 static int tab = 0;
457 ImGui::RadioButton("Markers", &tab, 0);
458 ImGui::SameLine();
459 ImGui::RadioButton("Maps", &tab, 1);
460 auto kind = (tab == 0) ? AssetKind::Marker : AssetKind::Map;
461 ImGui::Separator();
462 auto url_color = (kind == AssetKind::Marker) ? ImVec4(0.60f, 0.80f, 1.00f, 1.0f) /*BLUE*/ : ImVec4(0.45f, 0.95f, 0.55f, 1.0f) /*GREEN*/;
463 std::wstring url = L"https://example.com/some.png";
464 std::filesystem::path dst = assetsDir(kind);
465 std::string err;
466 ImGui::PushStyleColor(ImGuiCol_Text, url_color);
467 //ImGui::InputText("",url_color, url);
468 ImGui::PopStyleColor();
469 if (!AssetIO::importFromUrl(kind, url, &dst, &err))
470 {
471 std::cerr << "Import URL failed: " << err << "\n";
472 if (auto t = toaster_.lock(); t)
473 t->Push(ImGuiToaster::Level::Error, "Import URL failed: " + err);
474 }
475 else
476 {
477 if (auto t = toaster_.lock(); t)
478 t->Push(ImGuiToaster::Level::Good, "Image Imported from URL");
479 }
480
481 ImGui::Separator();
482 if (ImGui::Button("Close"))
483 ImGui::CloseCurrentPopup();
484 ImGui::EndPopup();
485 }
486 }
487} // namespace AssetIO
static fs::path getMarkersPath()
Definition PathManager.h:66
static fs::path getMapsPath()
Definition PathManager.h:61
AssetKind
Definition AssetIO.h:25
bool importFromPicker(AssetKind kind, std::filesystem::path *outDst=nullptr, std::string *outError=nullptr)
Definition AssetIO.h:176
bool deleteAsset(AssetKind kind, const std::filesystem::path &file, std::string *outError=nullptr)
Definition AssetIO.h:343
bool importFromPath(AssetKind kind, const std::filesystem::path &srcPath, std::filesystem::path *outDst=nullptr, std::string *outError=nullptr)
Definition AssetIO.h:140
std::vector< std::filesystem::path > pickImageFilesWin32()
Definition AssetIO.h:191
bool importFromUrl(AssetKind kind, const std::wstring &urlW, std::filesystem::path *outDst=nullptr, std::string *outError=nullptr)
Definition AssetIO.h:285
std::optional< std::filesystem::path > pickImageFileWin32()
Definition AssetIO.h:98
std::vector< std::filesystem::path > listAssets(AssetKind kind)
Definition AssetIO.h:325
std::filesystem::path uniqueName(const std::filesystem::path &destDir, const std::string &baseFile)
Definition AssetIO.h:78
const std::filesystem::path & assetsDir(AssetKind kind)
Definition AssetIO.h:30
void openUrlAssetPopUp(std::weak_ptr< ImGuiToaster > toaster_)
Definition AssetIO.h:451
void openDeleteAssetPopUp(std::weak_ptr< ImGuiToaster > toaster_)
Definition AssetIO.h:401
std::string slugify(const std::string &s)
Definition AssetIO.h:56
bool importManyFromPicker(AssetKind kind, std::vector< std::filesystem::path > *outDsts=nullptr, std::string *outError=nullptr)
Definition AssetIO.h:240
bool ok() const
Definition AssetIO.h:49