RunicVTT Open Source Virtual Tabletop for TTRPG using P2P
Loading...
Searching...
No Matches
NotesManager.cpp
Go to the documentation of this file.
1#include "NotesManager.h"
2#include "ImGuiToaster.h"
3
4#include <fstream>
5#include <sstream>
6#include <random>
7#include <iomanip>
8#include <algorithm>
9#include <cctype>
10
11using Clock = std::chrono::system_clock;
12
13static bool isMarkdownExt(const std::filesystem::path& p)
14{
15 auto ext = p.extension().string();
16 std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
17 return (ext == ".md" || ext == ".markdown");
18}
19
20NotesManager::NotesManager(NotesManagerConfig cfg, std::shared_ptr<ImGuiToaster> toaster) :
21 cfg_(std::move(cfg)), toaster_(std::move(toaster))
22{
23 std::error_code ec;
24 if (!cfg_.globalNotesDir.empty())
25 std::filesystem::create_directories(cfg_.globalNotesDir, ec);
26 if (!cfg_.gameTablesRootDir.empty())
27 std::filesystem::create_directories(cfg_.gameTablesRootDir, ec);
28}
29
31{
32 std::lock_guard<std::mutex> lk(mtx_);
33 notesById_.clear();
34
36
37 if (!cfg_.gameTablesRootDir.empty() && std::filesystem::exists(cfg_.gameTablesRootDir))
38 {
39 for (auto& dir : std::filesystem::directory_iterator(cfg_.gameTablesRootDir))
40 {
41 if (!dir.is_directory())
42 continue;
43 auto tableName = dir.path().filename().string();
44 auto notesDir = dir.path() / "Notes";
45 scanFolderForMd_(notesDir, tableName);
46 }
47 }
48 toastInfo("Notes loaded from disk.");
49}
50
52{
53 std::lock_guard<std::mutex> lk(mtx_);
55 toastInfo("Global notes loaded.");
56}
57
58void NotesManager::loadFromTable(const std::string& tableName)
59{
60 std::lock_guard<std::mutex> lk(mtx_);
61 auto notesDir = cfg_.gameTablesRootDir / tableName / "Notes";
62 scanFolderForMd_(notesDir, tableName);
63 toastInfo("Table notes loaded: " + tableName);
64}
65
66void NotesManager::scanFolderForMd_(const std::filesystem::path& dir,
67 std::optional<std::string> tableName)
68{
69 std::error_code ec;
70 if (dir.empty() || !std::filesystem::exists(dir, ec))
71 return;
72
73 for (auto& entry : std::filesystem::directory_iterator(dir, ec))
74 {
75 if (!entry.is_regular_file(ec))
76 continue;
77 if (!isMarkdownExt(entry.path()))
78 continue;
79
80 Note temp;
81 if (parseMarkdownFile(entry.path(), temp))
82 {
83 auto n = std::make_shared<Note>(std::move(temp));
84 n->file_path = entry.path();
85 n->saved_locally = true;
86 n->dirty = false;
87 n->inbox = false;
88 if (tableName)
89 n->table_name = tableName;
90 notesById_[n->uuid] = std::move(n);
91 }
92 }
93}
94
95std::string NotesManager::createNote(std::string title,
96 std::string author,
97 std::optional<std::string> tableName)
98{
99 std::lock_guard<std::mutex> lk(mtx_);
100
101 auto n = std::make_shared<Note>();
102 n->uuid = generateUUID();
103 n->title = std::move(title);
104 n->author = std::move(author);
105 n->table_name = std::move(tableName);
106 n->creation_ts = Clock::now();
107 n->last_update_ts = n->creation_ts;
108 n->saved_locally = false;
109 n->dirty = true;
110 n->shared = false;
111 n->inbox = false;
112 n->open_editor = true;
113
114 auto id = n->uuid;
115 notesById_.emplace(id, std::move(n));
116 toastGood("Note created: " + id);
117 return id;
118}
119
120bool NotesManager::saveNote(const std::string& uuid)
121{
122 std::lock_guard<std::mutex> lk(mtx_);
123 auto it = notesById_.find(uuid);
124 if (it == notesById_.end())
125 return false;
126 auto& n = *it->second;
127
128 std::error_code ec;
129 if (n.file_path.empty())
130 {
131 n.file_path = defaultSavePath(n);
132 }
133 std::filesystem::create_directories(n.file_path.parent_path(), ec);
134
135 n.last_update_ts = Clock::now();
136 if (!writeMarkdownFile(n.file_path, n))
137 {
138 toastError("Failed to save: " + n.title);
139 return false;
140 }
141 n.saved_locally = true;
142 n.dirty = false;
143 n.inbox = false;
144 toastGood("Saved: " + n.title);
145 return true;
146}
147
148bool NotesManager::saveNoteAs(const std::string& uuid, const std::filesystem::path& absolutePath)
149{
150 std::lock_guard<std::mutex> lk(mtx_);
151 auto it = notesById_.find(uuid);
152 if (it == notesById_.end())
153 return false;
154 auto& n = *it->second;
155
156 std::error_code ec;
157 std::filesystem::create_directories(absolutePath.parent_path(), ec);
158
159 n.last_update_ts = Clock::now();
160 if (!writeMarkdownFile(absolutePath, n))
161 {
162 toastError("Failed to save as: " + absolutePath.string());
163 return false;
164 }
165 n.file_path = absolutePath;
166 n.saved_locally = true;
167 n.dirty = false;
168 n.inbox = false;
169 toastGood("Saved as: " + absolutePath.filename().string());
170 return true;
171}
172
173bool NotesManager::deleteNote(const std::string& uuid, bool deleteFromDisk)
174{
175 std::lock_guard<std::mutex> lk(mtx_);
176 auto it = notesById_.find(uuid);
177 if (it == notesById_.end())
178 return false;
179
180 if (deleteFromDisk && !it->second->file_path.empty())
181 {
182 std::error_code ec;
183 std::filesystem::remove(it->second->file_path, ec);
184 }
185 notesById_.erase(it);
186 toastWarn("Note deleted");
187 return true;
188}
189
190std::shared_ptr<Note> NotesManager::getNote(const std::string& uuid)
191{
192 std::lock_guard<std::mutex> lk(mtx_);
193 auto it = notesById_.find(uuid);
194 return (it == notesById_.end() ? nullptr : it->second);
195}
196
197std::shared_ptr<const Note> NotesManager::getNote(const std::string& uuid) const
198{
199 std::lock_guard<std::mutex> lk(mtx_);
200 auto it = notesById_.find(uuid);
201 return (it == notesById_.end() ? nullptr : it->second);
202}
203
204std::vector<std::shared_ptr<Note>> NotesManager::listAll()
205{
206 std::lock_guard<std::mutex> lk(mtx_);
207 std::vector<std::shared_ptr<Note>> v;
208 v.reserve(notesById_.size());
209 for (auto& kv : notesById_)
210 v.push_back(kv.second);
211 return v;
212}
213
214std::vector<std::shared_ptr<Note>> NotesManager::listMyNotes()
215{
216 std::lock_guard<std::mutex> lk(mtx_);
217 std::vector<std::shared_ptr<Note>> v;
218 v.reserve(notesById_.size());
219 for (auto& kv : notesById_)
220 {
221 if (!kv.second->inbox)
222 v.push_back(kv.second);
223 }
224 return v;
225}
226
227std::vector<std::shared_ptr<Note>> NotesManager::listInbox()
228{
229 std::lock_guard<std::mutex> lk(mtx_);
230 std::vector<std::shared_ptr<Note>> v;
231 v.reserve(notesById_.size());
232 for (auto& kv : notesById_)
233 {
234 if (kv.second->inbox)
235 v.push_back(kv.second);
236 }
237 return v;
238}
239
240void NotesManager::setTitle(const std::string& uuid, std::string title)
241{
242 std::lock_guard<std::mutex> lk(mtx_);
243 auto it = notesById_.find(uuid);
244 if (it == notesById_.end())
245 return;
246 it->second->title = std::move(title);
247 it->second->dirty = true;
248 it->second->last_update_ts = Clock::now();
249}
250
251void NotesManager::setContent(const std::string& uuid, std::string md)
252{
253 std::lock_guard<std::mutex> lk(mtx_);
254 auto it = notesById_.find(uuid);
255 if (it == notesById_.end())
256 return;
257 it->second->markdown_text = std::move(md);
258 it->second->dirty = true;
259 it->second->last_update_ts = Clock::now();
260}
261
262void NotesManager::setAuthor(const std::string& uuid, std::string author)
263{
264 std::lock_guard<std::mutex> lk(mtx_);
265 auto it = notesById_.find(uuid);
266 if (it == notesById_.end())
267 return;
268 it->second->author = std::move(author);
269 it->second->dirty = true;
270 it->second->last_update_ts = Clock::now();
271}
272
273std::string NotesManager::upsertSharedIncoming(std::string uuid,
274 std::string title,
275 std::string author,
276 std::string markdown,
277 std::optional<std::string> fromPeerName)
278{
279 std::lock_guard<std::mutex> lk(mtx_);
280 if (uuid.empty())
281 uuid = generateUUID();
282
283 auto it = notesById_.find(uuid);
284 if (it == notesById_.end())
285 {
286 auto n = std::make_shared<Note>();
287 n->uuid = uuid;
288 n->title = std::move(title);
289 n->author = std::move(author);
290 n->markdown_text = std::move(markdown);
291 n->creation_ts = Clock::now();
292 n->last_update_ts = n->creation_ts;
293 n->saved_locally = false;
294 n->dirty = false;
295 n->shared = true;
296 n->inbox = true;
297 n->shared_from = std::move(fromPeerName);
298 n->open_editor = false;
299 notesById_.emplace(uuid, std::move(n));
300 }
301 else
302 {
303 auto& n = *it->second;
304 n.title = std::move(title);
305 n.author = std::move(author);
306 n.markdown_text = std::move(markdown);
307 n.last_update_ts = Clock::now();
308 n.shared = true;
309 n.inbox = true;
310 n.shared_from = std::move(fromPeerName);
311 n.dirty = false;
312 }
313 return uuid;
314}
315
316bool NotesManager::saveInboxToLocal(const std::string& uuid,
317 std::optional<std::string> tableName)
318{
319 std::lock_guard<std::mutex> lk(mtx_);
320 auto it = notesById_.find(uuid);
321 if (it == notesById_.end())
322 return false;
323 auto& n = *it->second;
324
325 if (tableName)
326 n.table_name = tableName;
327
328 std::error_code ec;
329 if (n.file_path.empty())
330 {
331 n.file_path = defaultSavePath(n);
332 }
333 std::filesystem::create_directories(n.file_path.parent_path(), ec);
334
335 n.last_update_ts = Clock::now();
336 if (!writeMarkdownFile(n.file_path, n))
337 {
338 toastError("Failed to save inbox note.");
339 return false;
340 }
341 n.saved_locally = true;
342 n.inbox = false;
343 toastGood("Saved to disk: " + n.title);
344 return true;
345}
346
347std::filesystem::path NotesManager::defaultSavePath(const Note& n) const
348{
349 auto slug = slugify(n.title.empty() ? "note" : n.title);
350 auto fname = slug + "__" + n.uuid + ".md";
351 if (n.table_name)
352 return cfg_.gameTablesRootDir / *n.table_name / "Notes" / fname;
353 return cfg_.globalNotesDir / fname;
354}
355
356// ------------- utils (same as before) -------------
358{
359 std::random_device rd;
360 std::mt19937_64 gen(rd());
361 uint64_t a = gen(), b = gen();
362 std::ostringstream oss;
363 oss << std::hex << std::nouppercase << std::setfill('0')
364 << std::setw(16) << a << std::setw(16) << b;
365 return oss.str();
366}
367
368std::string NotesManager::slugify(const std::string& s)
369{
370 std::string out;
371 out.reserve(s.size());
372 for (unsigned char c : s)
373 {
374 if (std::isalnum(c))
375 out.push_back((char)std::tolower(c));
376 else if (c == ' ' || c == '-' || c == '_')
377 out.push_back('-');
378 }
379 while (!out.empty() && out.back() == '-')
380 out.pop_back();
381 if (out.empty())
382 out = "note";
383 return out;
384}
385
386int64_t NotesManager::toEpochMillis(std::chrono::system_clock::time_point tp)
387{
388 return std::chrono::duration_cast<std::chrono::milliseconds>(tp.time_since_epoch()).count();
389}
390std::chrono::system_clock::time_point NotesManager::fromEpochMillis(int64_t ms)
391{
392 return std::chrono::system_clock::time_point(std::chrono::milliseconds(ms));
393}
394
395// front-matter parse/write (same as previous version)
396bool NotesManager::parseMarkdownFile(const std::filesystem::path& path, Note& outNote)
397{
398 std::ifstream in(path, std::ios::binary);
399 if (!in)
400 return false;
401 std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
402 in.close();
403
404 size_t pos = 0;
405 auto startsWith = [&](const char* s)
406 { return text.compare(pos, std::strlen(s), s) == 0; };
407
408 std::unordered_map<std::string, std::string> meta;
409 std::string body;
410
411 if (startsWith("---"))
412 {
413 pos += 3;
414 while (pos < text.size() && (text[pos] == '\r' || text[pos] == '\n'))
415 ++pos;
416 while (pos < text.size())
417 {
418 if (text.compare(pos, 3, "---") == 0)
419 {
420 pos += 3;
421 while (pos < text.size() && (text[pos] == '\r' || text[pos] == '\n'))
422 ++pos;
423 break;
424 }
425 size_t lineEnd = text.find_first_of("\r\n", pos);
426 std::string line = (lineEnd == std::string::npos) ? text.substr(pos)
427 : text.substr(pos, lineEnd - pos);
428 if (lineEnd == std::string::npos)
429 pos = text.size();
430 else
431 {
432 pos = lineEnd;
433 while (pos < text.size() && (text[pos] == '\r' || text[pos] == '\n'))
434 ++pos;
435 }
436 auto colon = line.find(':');
437 if (colon != std::string::npos)
438 {
439 auto k = line.substr(0, colon);
440 auto v = line.substr(colon + 1);
441 auto ltrim = [](std::string& s)
442 { s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch)
443 { return !std::isspace(ch); })); };
444 auto rtrim = [](std::string& s)
445 { s.erase(std::find_if(s.rbegin(), s.rend(), [](int ch)
446 { return !std::isspace(ch); })
447 .base(),
448 s.end()); };
449 auto trim = [&](std::string& s)
450 { ltrim(s); rtrim(s); };
451 trim(k);
452 trim(v);
453 meta[k] = v;
454 }
455 }
456 body = (pos < text.size()) ? text.substr(pos) : std::string{};
457 }
458 else
459 {
460 body = text;
461 }
462
463 Note n;
464 n.uuid = meta.count("uuid") ? meta["uuid"] : generateUUID();
465 n.title = meta.count("title") ? meta["title"] : path.stem().string();
466 n.author = meta.count("author") ? meta["author"] : "unknown";
467 if (meta.count("creation_ts"))
468 {
469 try
470 {
471 n.creation_ts = fromEpochMillis(std::stoll(meta["creation_ts"]));
472 }
473 catch (...)
474 {
475 n.creation_ts = Clock::now();
476 }
477 }
478 else
479 n.creation_ts = Clock::now();
480 if (meta.count("last_update_ts"))
481 {
482 try
483 {
484 n.last_update_ts = fromEpochMillis(std::stoll(meta["last_update_ts"]));
485 }
486 catch (...)
487 {
488 n.last_update_ts = n.creation_ts;
489 }
490 }
491 else
492 n.last_update_ts = n.creation_ts;
493
494 if (meta.count("table") && !meta["table"].empty())
495 n.table_name = meta["table"];
496 if (meta.count("shared"))
497 n.shared = (meta["shared"] == "1");
498 if (meta.count("shared_from") && !meta["shared_from"].empty())
499 n.shared_from = meta["shared_from"];
500
501 n.markdown_text = std::move(body);
502 outNote = std::move(n);
503 return true;
504}
505
506bool NotesManager::writeMarkdownFile(const std::filesystem::path& path, const Note& note)
507{
508 std::ofstream out(path, std::ios::binary);
509 if (!out)
510 return false;
511
512 auto c_ms = toEpochMillis(note.creation_ts);
513 auto u_ms = toEpochMillis(note.last_update_ts);
514
515 out << "---\n";
516 out << "uuid: " << note.uuid << "\n";
517 out << "title: " << note.title << "\n";
518 out << "author: " << note.author << "\n";
519 out << "creation_ts: " << c_ms << "\n";
520 out << "last_update_ts: " << u_ms << "\n";
521 out << "table: " << (note.table_name ? *note.table_name : "") << "\n";
522 out << "shared: " << (note.shared ? "1" : "0") << "\n";
523 out << "shared_from: " << (note.shared_from ? *note.shared_from : "") << "\n";
524 out << "---\n";
525 out << note.markdown_text;
526 return true;
527}
528
529// Toaster helpers
530void NotesManager::toastInfo(const std::string& msg) const
531{
532 if (toaster_)
534}
535void NotesManager::toastGood(const std::string& msg) const
536{
537 if (toaster_)
539}
540void NotesManager::toastWarn(const std::string& msg) const
541{
542 if (toaster_)
544}
545void NotesManager::toastError(const std::string& msg) const
546{
547 if (toaster_)
549}
550
551std::shared_ptr<Note> NotesManager::getByUuid(const std::string& uuid) const
552{
553 auto it = notesByUuid_.find(uuid);
554 return (it != notesByUuid_.end()) ? it->second : nullptr;
555}
556
557void NotesManager::indexNote(const std::shared_ptr<Note>& n)
558{
559 if (!n)
560 return;
561 notesByUuid_[n->uuid] = n;
562
563 // exact, case-insensitive index for title
564 if (!n->title.empty())
565 {
566 titleToUuid_[toLower_(n->title)] = n->uuid;
567 }
568}
569
570void NotesManager::removeFromIndex(const std::string& uuid)
571{
572 auto it = notesByUuid_.find(uuid);
573 if (it == notesByUuid_.end())
574 return;
575
576 // erase title index if it points to this uuid
577 if (!it->second->title.empty())
578 {
579 auto key = toLower_(it->second->title);
580 auto jt = titleToUuid_.find(key);
581 if (jt != titleToUuid_.end() && jt->second == uuid)
582 {
583 titleToUuid_.erase(jt);
584 }
585 }
586 notesByUuid_.erase(it);
587}
588
589std::string NotesManager::resolveRef(const std::string& ref) const
590{
591 if (ref.empty())
592 return {};
593
594 // 1) full UUID? (very loose validation) and it exists
595 if (looksLikeUuid_(ref))
596 {
597 if (notesByUuid_.find(ref) != notesByUuid_.end())
598 return ref;
599 // if it "looks like uuid" but doesn't exist, fall through to title (user may have typed a title with dashes)
600 }
601
602 // 2) title (case-insensitive exact match)
603 {
604 auto it = titleToUuid_.find(toLower_(ref));
605 if (it != titleToUuid_.end())
606 return it->second;
607 }
608
609 // 3) short id (>=8 hex chars, no dashes) — optional bonus
610 if (looksLikeShortHex_(ref))
611 {
612 std::string matchUuid;
613 bool ambig = false;
614 for (const auto& kv : notesByUuid_)
615 {
616 const std::string& uuid = kv.first; // canonical 36-char UUID
617 // compare only hex chars (ignore dashes) at the front
618 std::string compact;
619 compact.reserve(32);
620 for (char c : uuid)
621 if (c != '-')
622 compact.push_back((char)std::tolower((unsigned char)c));
623
624 std::string needle = toLower_(ref);
625 if (compact.rfind(needle, 0) == 0)
626 { // prefix match
627 if (matchUuid.empty())
628 matchUuid = uuid;
629 else
630 {
631 ambig = true;
632 break;
633 }
634 }
635 }
636 if (!ambig && !matchUuid.empty())
637 return matchUuid;
638 // ambiguous or not found → fall through
639 }
640
641 return {};
642}
643
644// ---------------- helpers ----------------
645bool NotesManager::looksLikeUuid_(const std::string& s)
646{
647 // Very loose: 8-4-4-4-12 hex + dashes
648 if (s.size() != 36)
649 return false;
650 const int dashPos[4] = {8, 13, 18, 23};
651 for (int i = 0; i < 36; ++i)
652 {
653 if (i == dashPos[0] || i == dashPos[1] || i == dashPos[2] || i == dashPos[3])
654 {
655 if (s[i] != '-')
656 return false;
657 }
658 else if (!std::isxdigit((unsigned char)s[i]))
659 {
660 return false;
661 }
662 }
663 return true;
664}
665
666bool NotesManager::looksLikeShortHex_(const std::string& s)
667{
668 if (s.size() < 8)
669 return false; // minimum 8 for usefulness
670 for (char c : s)
671 {
672 if (c == '-')
673 return false;
674 if (!std::isxdigit((unsigned char)c))
675 return false;
676 }
677 return true;
678}
679
680std::string NotesManager::toLower_(const std::string& s)
681{
682 std::string out;
683 out.resize(s.size());
684 std::transform(s.begin(), s.end(), out.begin(),
685 [](unsigned char c)
686 { return (char)std::tolower(c); });
687 return out;
688}
static bool isMarkdownExt(const std::filesystem::path &p)
std::chrono::steady_clock Clock
static bool parseMarkdownFile(const std::filesystem::path &path, Note &outNote)
bool saveInboxToLocal(const std::string &uuid, std::optional< std::string > tableName=std::nullopt)
static bool looksLikeShortHex_(const std::string &s)
static std::string toLower_(const std::string &s)
void toastGood(const std::string &msg) const
void setTitle(const std::string &uuid, std::string title)
std::string createNote(std::string title, std::string author, std::optional< std::string > tableName=std::nullopt)
NotesManagerConfig cfg_
static std::string slugify(const std::string &title)
std::vector< std::shared_ptr< Note > > listInbox()
std::unordered_map< std::string, std::string > titleToUuid_
NotesManager(NotesManagerConfig cfg, std::shared_ptr< ImGuiToaster > toaster)
std::string resolveRef(const std::string &ref) const
std::unordered_map< std::string, std::shared_ptr< Note > > notesByUuid_
std::shared_ptr< Note > getByUuid(const std::string &uuid) const
void removeFromIndex(const std::string &uuid)
void toastWarn(const std::string &msg) const
bool deleteNote(const std::string &uuid, bool deleteFromDisk)
void indexNote(const std::shared_ptr< Note > &n)
std::vector< std::shared_ptr< Note > > listMyNotes()
bool saveNote(const std::string &uuid)
void setContent(const std::string &uuid, std::string md)
bool saveNoteAs(const std::string &uuid, const std::filesystem::path &absolutePath)
void setAuthor(const std::string &uuid, std::string author)
void loadFromGlobal()
void toastError(const std::string &msg) const
static std::string generateUUID()
std::mutex mtx_
std::string upsertSharedIncoming(std::string uuid, std::string title, std::string author, std::string markdown, std::optional< std::string > fromPeerName)
void scanFolderForMd_(const std::filesystem::path &dir, std::optional< std::string > tableName)
static bool looksLikeUuid_(const std::string &s)
std::vector< std::shared_ptr< Note > > listAll()
void toastInfo(const std::string &msg) const
static std::chrono::system_clock::time_point fromEpochMillis(int64_t ms)
void loadAllFromDisk()
std::shared_ptr< ImGuiToaster > toaster_
std::shared_ptr< Note > getNote(const std::string &uuid)
static int64_t toEpochMillis(std::chrono::system_clock::time_point tp)
void loadFromTable(const std::string &tableName)
std::unordered_map< std::string, std::shared_ptr< Note > > notesById_
std::filesystem::path defaultSavePath(const Note &note) const
static bool writeMarkdownFile(const std::filesystem::path &path, const Note &note)
Definition Message.h:28
std::string uuid
std::filesystem::path gameTablesRootDir
std::filesystem::path globalNotesDir