diff --git a/assets/translations/en.json b/assets/translations/en.json index ad2daa813f..6268ff31e9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -932,9 +932,11 @@ "custom": "CalDAV", "google": "Google", "icloud": "iCloud", - "ics": "ICS URL" + "ics": "ICS URL", + "vdir": "Local (vdir)" }, "provider-label": "Provider", + "vdir-path-label": "Directory Path", "save": "Save", "save-connect": "Save and Connect", "save-error": "Could not save calendar account.", diff --git a/meson.build b/meson.build index 91ea021c6f..4c3d444026 100644 --- a/meson.build +++ b/meson.build @@ -435,6 +435,7 @@ _noctalia_sources = files( 'src/calendar/google_client.cpp', 'src/calendar/google_oauth.cpp', 'src/calendar/ical_parser.cpp', + 'src/calendar/vdir_reader.cpp', 'src/config/atomic_file.cpp', 'src/config/config_export.cpp', 'src/config/config_merge.cpp', @@ -1206,6 +1207,7 @@ if build_tests 'widget_action', 'widget_definition', 'wallpaper_shuffle_state', + 'vdir_reader', 'workspace_alert_service', ] diff --git a/src/calendar/calendar_poll_source.h b/src/calendar/calendar_poll_source.h index 005d1c0694..079c9afabd 100644 --- a/src/calendar/calendar_poll_source.h +++ b/src/calendar/calendar_poll_source.h @@ -8,10 +8,12 @@ class CalendarPollSource final : public PollSource { explicit CalendarPollSource(CalendarService& calendar) : m_calendar(calendar) {} [[nodiscard]] int pollTimeoutMs() const override { return m_calendar.pollTimeoutMs(); } - void dispatch(const std::vector& /*fds*/, std::size_t /*startIdx*/) override { m_calendar.tick(); } + void dispatch(const std::vector& fds, std::size_t startIdx) override { + m_calendar.dispatchPoll(fds, startIdx); + } protected: - void doAddPollFds(std::vector& /*fds*/) override {} + void doAddPollFds(std::vector& fds) override { m_calendar.addPollFds(fds); } private: CalendarService& m_calendar; diff --git a/src/calendar/calendar_service.cpp b/src/calendar/calendar_service.cpp index 62e47435b1..9b2157aaba 100644 --- a/src/calendar/calendar_service.cpp +++ b/src/calendar/calendar_service.cpp @@ -6,6 +6,7 @@ #include "calendar/calendar_discovery_state.h" #include "calendar/event_link.h" #include "calendar/ical_parser.h" +#include "calendar/vdir_reader.h" #include "config/config_service.h" #include "core/log.h" #include "i18n/i18n.h" @@ -109,6 +110,7 @@ void CalendarService::initialize() { m_initialized = true; syncCachePersistence(); initializeCredentials(); + updateVdirWatches(); } void CalendarService::syncCachePersistence() { @@ -290,6 +292,7 @@ void CalendarService::onConfigReload() { return; } m_activeConfig = next; + updateVdirWatches(); std::unordered_set activeAccountIds; activeAccountIds.reserve(m_activeConfig.accounts.size()); for (const CalendarConfig::Account& account : m_activeConfig.accounts) { @@ -325,6 +328,9 @@ int CalendarService::pollTimeoutMs() const { timeout = timeout < 0 ? clamped : std::min(timeout, clamped); }; + if (m_vdirDebouncePending) { + consider(m_vdirDebounceUntil); + } if (m_connect.state == ConnectState::Pending && !m_connect.inFlight) { consider(m_connect.nextPollAt); } @@ -337,6 +343,11 @@ int CalendarService::pollTimeoutMs() const { void CalendarService::tick() { const auto now = std::chrono::steady_clock::now(); + if (m_vdirDebouncePending && now >= m_vdirDebounceUntil) { + m_vdirDebouncePending = false; + requestRefresh(); + } + if (m_connect.state == ConnectState::Pending && !m_connect.inFlight && now >= m_connect.nextPollAt) { if (now >= m_connect.deadline) { kLog.warn("google connect timed out for account {}", m_connect.accountId); @@ -353,6 +364,25 @@ void CalendarService::tick() { } } +void CalendarService::addPollFds(std::vector& fds) { + if (m_vdirInotify.fd() >= 0) { + fds.push_back({.fd = m_vdirInotify.fd(), .events = POLLIN, .revents = 0}); + } +} + +void CalendarService::dispatchPoll(const std::vector& fds, std::size_t startIdx) { + if (m_vdirInotify.fd() >= 0 && startIdx < fds.size() && (fds[startIdx].revents & POLLIN) != 0) { + bool changed = false; + m_vdirInotify.drain([&](const inotify_event* /*event*/) { changed = true; }); + if (changed) { + m_vdirDebouncePending = true; + m_vdirDebounceUntil = std::chrono::steady_clock::now() + std::chrono::milliseconds{300}; + notifyChanged(); + } + } + tick(); +} + void CalendarService::scheduleNextRefresh() { const int minutes = std::max(1, m_activeConfig.refreshMinutes); m_nextRefreshAt = std::chrono::steady_clock::now() + std::chrono::minutes{minutes}; @@ -380,6 +410,8 @@ void CalendarService::startRefresh() { fetchGoogle(account); } else if (account.type == "ics") { fetchIcs(account); + } else if (account.type == "vdir" || account.type == "local") { + fetchVdir(account); } else { kLog.warn("unknown calendar account type '{}' for id {}", account.type, account.id); accountDone(account.id, false, {}); @@ -626,6 +658,97 @@ void CalendarService::fetchIcs(const CalendarConfig::Account& account) { }); } +void CalendarService::updateVdirWatches() { + if (!m_activeConfig.enabled) { + return; + } + + std::set targetPaths; + for (const CalendarConfig::Account& account : m_activeConfig.accounts) { + if (account.type != "vdir" && account.type != "local") { + continue; + } + const std::filesystem::path rootPath = + account.path.empty() ? calendar::defaultVdirPath() : std::filesystem::path(account.path); + std::error_code ec; + if (!std::filesystem::exists(rootPath, ec) || !std::filesystem::is_directory(rootPath, ec)) { + continue; + } + + targetPaths.insert(rootPath); + auto collections = calendar::discoverVdirCollections(rootPath); + for (const auto& col : collections) { + targetPaths.insert(col.path); + } + } + + constexpr std::uint32_t mask = IN_CREATE | IN_DELETE | IN_MODIFY | IN_MOVED_TO | IN_MOVED_FROM | IN_CLOSE_WRITE; + for (const auto& path : targetPaths) { + if (!m_watchedVdirPaths.contains(path)) { + if (m_vdirInotify.watch(path, mask).has_value()) { + m_watchedVdirPaths.insert(path); + } + } + } +} + +void CalendarService::fetchVdir(const CalendarConfig::Account& account) { + const std::filesystem::path rootPath = + account.path.empty() ? calendar::defaultVdirPath() : std::filesystem::path(account.path); + std::error_code ec; + if (!std::filesystem::exists(rootPath, ec) || !std::filesystem::is_directory(rootPath, ec)) { + kLog.warn("vdir account {} path does not exist or is not a directory: {}", account.id, rootPath.string()); + accountDone(account.id, false, {}); + return; + } + + auto collections = calendar::discoverVdirCollections(rootPath); + if (collections.empty()) { + kLog.warn("vdir account {} found no calendar collections in {}", account.id, rootPath.string()); + accountDone(account.id, false, {}); + return; + } + + updateVdirWatches(); + + std::vector sources; + sources.reserve(collections.size()); + for (const auto& col : collections) { + sources.push_back({ + .id = col.id, + .name = col.name, + }); + } + (void)m_configService.setStateString( + kCalendarDiscoveryOwner, account.id + "_calendars", calendar::serializeCalendarSources(sources) + ); + + const std::vector selectedIds = calendar::selectedCalendarSourceIds(sources, account.calendars); + std::erase_if(collections, [&](const calendar::VdirCollection& col) { + return !std::ranges::contains(selectedIds, col.id); + }); + + const auto now = std::chrono::system_clock::now(); + const auto windowStart = now - kWindowBefore; + const auto windowEnd = now + kWindowAfter; + + std::vector allEvents; + calendar::ICalParseControl control{}; + + for (auto& col : collections) { + if (!account.displayName.empty() && collections.size() == 1) { + col.name = account.displayName; + } + if (!account.color.empty()) { + col.colorHex = account.color; + } + auto events = calendar::loadVdirCollectionEvents(col, windowStart, windowEnd, control); + allEvents.insert(allEvents.end(), std::make_move_iterator(events.begin()), std::make_move_iterator(events.end())); + } + + accountDone(account.id, true, std::move(allEvents)); +} + void CalendarService::refreshGoogleToken(const std::string& accountId, std::function cb) { m_credentials.lookupRefreshToken( accountId, diff --git a/src/calendar/calendar_service.h b/src/calendar/calendar_service.h index 960b104f7a..917204581e 100644 --- a/src/calendar/calendar_service.h +++ b/src/calendar/calendar_service.h @@ -6,6 +6,7 @@ #include "calendar/google_client.h" #include "calendar/google_oauth.h" #include "config/config_types.h" +#include "core/inotify/inotify.h" #include "security/storage_key_provider.h" #include @@ -14,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -71,6 +74,8 @@ class CalendarService { [[nodiscard]] int pollTimeoutMs() const; void tick(); + void addPollFds(std::vector& fds); + void dispatchPoll(const std::vector& fds, std::size_t startIdx); [[nodiscard]] bool enabled() const noexcept { return m_activeConfig.enabled; } [[nodiscard]] bool hasData() const noexcept { return m_snapshot.valid; } @@ -127,6 +132,8 @@ class CalendarService { const CalendarConfig::Account& account, calendar::CalendarCredentialStore::LookupCallback callback ); void fetchIcs(const CalendarConfig::Account& account); + void fetchVdir(const CalendarConfig::Account& account); + void updateVdirWatches(); void fetchGoogle(const CalendarConfig::Account& account); void refreshGoogleToken(const std::string& accountId, std::function cb); void googleFetchWithToken(const std::string& accountId, const std::string& accessToken, bool allowRefreshRetry); @@ -182,4 +189,8 @@ class CalendarService { bool m_googleCredentialLockedNotificationShown = false; ConnectFlow m_connect; calendar::CalDavClient m_caldav; + Inotify m_vdirInotify; + std::set m_watchedVdirPaths; + std::chrono::steady_clock::time_point m_vdirDebounceUntil; + bool m_vdirDebouncePending = false; }; diff --git a/src/calendar/vdir_reader.cpp b/src/calendar/vdir_reader.cpp new file mode 100644 index 0000000000..5f5185dd9b --- /dev/null +++ b/src/calendar/vdir_reader.cpp @@ -0,0 +1,268 @@ +#include "calendar/vdir_reader.h" + +#include "calendar/ical_parser.h" +#include "core/log.h" +#include "util/string_utils.h" + +#include +#include +#include +#include + +namespace calendar { + + namespace { + constexpr Logger kLog("vdir-reader"); + + std::string readTrimmedFile(const std::filesystem::path& path) { + std::error_code ec; + if (!std::filesystem::is_regular_file(path, ec)) { + return {}; + } + std::ifstream file(path, std::ios::binary); + if (!file) { + return {}; + } + std::ostringstream ss; + ss << file.rdbuf(); + return StringUtils::trim(ss.str()); + } + + bool isValidColorHex(std::string_view hex) { + if (hex.size() != 7 && hex.size() != 9) { + return false; + } + if (hex.front() != '#') { + return false; + } + return std::all_of(hex.begin() + 1, hex.end(), [](char c) { + return std::isxdigit(static_cast(c)) != 0; + }); + } + + std::string extractCalNameFromIcs(std::string_view ics) { + // Look for X-WR-CALNAME: or X-WR-CALNAME;...: + constexpr std::string_view kCalNameKey = "X-WR-CALNAME"; + std::size_t pos = 0; + while (pos < ics.size()) { + std::size_t lineEnd = ics.find('\n', pos); + if (lineEnd == std::string_view::npos) { + lineEnd = ics.size(); + } + std::string_view line = ics.substr(pos, lineEnd - pos); + if (!line.empty() && line.back() == '\r') { + line.remove_suffix(1); + } + + if (line.size() >= kCalNameKey.size()) { + std::string_view prefix = line.substr(0, kCalNameKey.size()); + if (StringUtils::equalsInsensitive(prefix, kCalNameKey)) { + std::size_t colon = line.find(':', kCalNameKey.size()); + if (colon != std::string_view::npos) { + std::string value = StringUtils::trim(line.substr(colon + 1)); + if (!value.empty()) { + return value; + } + } + } + } + + if (lineEnd >= ics.size()) { + break; + } + pos = lineEnd + 1; + } + return {}; + } + + bool isIcsFile(const std::filesystem::path& path) { + const std::string filename = path.filename().string(); + if (filename.empty() || filename.front() == '.' || filename.ends_with(".tmp")) { + return false; + } + return path.extension() == ".ics"; + } + + bool hasIcsFiles(const std::filesystem::path& dirPath) { + std::error_code ec; + if (!std::filesystem::is_directory(dirPath, ec)) { + return false; + } + for (const auto& entry : std::filesystem::directory_iterator( + dirPath, std::filesystem::directory_options::skip_permission_denied, ec + )) { + if (entry.is_regular_file(ec) && isIcsFile(entry.path())) { + return true; + } + } + return false; + } + + VdirCollection buildCollection(const std::filesystem::path& rootPath, const std::filesystem::path& dirPath) { + VdirCollection col; + col.path = dirPath; + + std::error_code ec; + if (rootPath == dirPath) { + col.id = dirPath.filename().string(); + } else { + col.id = std::filesystem::relative(dirPath, rootPath, ec).generic_string(); + } + if (col.id.empty()) { + col.id = dirPath.filename().string(); + } + + // Read displayname file + std::string displayName = readTrimmedFile(dirPath / "displayname"); + if (displayName.empty()) { + // Try reading X-WR-CALNAME from the first .ics file + for (const auto& entry : std::filesystem::directory_iterator( + dirPath, std::filesystem::directory_options::skip_permission_denied, ec + )) { + if (entry.is_regular_file(ec) && isIcsFile(entry.path())) { + std::ifstream icsFile(entry.path(), std::ios::binary); + if (icsFile) { + std::string header; + header.resize(4096); + icsFile.read(header.data(), static_cast(header.size())); + header.resize(static_cast(icsFile.gcount())); + displayName = extractCalNameFromIcs(header); + if (!displayName.empty()) { + break; + } + } + } + } + } + + if (displayName.empty()) { + col.name = dirPath.filename().string(); + } else { + col.name = std::move(displayName); + } + + // Read color file + std::string color = readTrimmedFile(dirPath / "color"); + if (isValidColorHex(color)) { + col.colorHex = std::move(color); + } + + // Read order file + std::string orderStr = readTrimmedFile(dirPath / "order"); + if (!orderStr.empty()) { + try { + col.order = std::stoi(orderStr); + } catch (...) { + col.order = 0; + } + } + + return col; + } + } // namespace + + std::filesystem::path defaultVdirPath() { + if (const char* xdgData = std::getenv("XDG_DATA_HOME"); xdgData != nullptr && *xdgData != '\0') { + return std::filesystem::path(xdgData) / "calendars"; + } + if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') { + return std::filesystem::path(home) / ".local" / "share" / "calendars"; + } + return std::filesystem::path(".local/share/calendars"); + } + + std::vector discoverVdirCollections(const std::filesystem::path& rootPath, int maxDepth) { + std::vector collections; + std::error_code ec; + + if (!std::filesystem::exists(rootPath, ec) || !std::filesystem::is_directory(rootPath, ec)) { + return collections; + } + + // Direct collection check + if (hasIcsFiles(rootPath)) { + collections.push_back(buildCollection(rootPath, rootPath)); + return collections; + } + + auto opts = std::filesystem::directory_options::skip_permission_denied; + for (auto it = std::filesystem::recursive_directory_iterator(rootPath, opts, ec); + it != std::filesystem::recursive_directory_iterator();) { + if (it.depth() > maxDepth) { + it.pop(); + continue; + } + + const auto& entry = *it; + if (entry.is_directory(ec)) { + const std::string dirname = entry.path().filename().string(); + if (!dirname.empty() && (dirname.front() == '.' || dirname.ends_with(".tmp"))) { + it.disable_recursion_pending(); + it.increment(ec); + continue; + } + + if (hasIcsFiles(entry.path())) { + collections.push_back(buildCollection(rootPath, entry.path())); + it.disable_recursion_pending(); + } + } + + it.increment(ec); + } + + std::ranges::sort(collections, [](const VdirCollection& a, const VdirCollection& b) { + if (a.order != b.order) { + return a.order < b.order; + } + return StringUtils::naturalCaseInsensitiveCompare(a.id, b.id) < 0; + }); + + return collections; + } + + std::vector loadVdirCollectionEvents( + const VdirCollection& collection, std::chrono::system_clock::time_point windowStart, + std::chrono::system_clock::time_point windowEnd, ICalParseControl& control + ) { + std::vector events; + std::error_code ec; + + if (!std::filesystem::is_directory(collection.path, ec)) { + return events; + } + + for (const auto& entry : std::filesystem::directory_iterator( + collection.path, std::filesystem::directory_options::skip_permission_denied, ec + )) { + if (!entry.is_regular_file(ec) || !isIcsFile(entry.path())) { + continue; + } + + std::ifstream file(entry.path(), std::ios::binary); + if (!file) { + continue; + } + std::ostringstream ss; + ss << file.rdbuf(); + const std::string content = ss.str(); + if (content.empty()) { + continue; + } + + auto result = parseICalEvents(content, windowStart, windowEnd, control); + for (auto& ev : result.events) { + if (ev.calendarName.empty()) { + ev.calendarName = collection.name; + } + if (ev.colorHex.empty() && !collection.colorHex.empty()) { + ev.colorHex = collection.colorHex; + } + events.push_back(std::move(ev)); + } + } + + return events; + } + +} // namespace calendar diff --git a/src/calendar/vdir_reader.h b/src/calendar/vdir_reader.h new file mode 100644 index 0000000000..f46ac79c79 --- /dev/null +++ b/src/calendar/vdir_reader.h @@ -0,0 +1,36 @@ +#pragma once + +#include "calendar/calendar_types.h" +#include "calendar/ical_parser.h" + +#include +#include +#include +#include + +namespace calendar { + + struct VdirCollection { + std::string id; // Relative path or directory name identifier, e.g. "fastmail_cal/05059f01-..." + std::string name; // Display name (from displayname file, X-WR-CALNAME, or fallback) + std::string colorHex; // Hex color (e.g. "#4285F4" from color file) + int order = 0; // Order value from 'order' file if present + std::filesystem::path path; // Full filesystem path to the directory containing *.ics files + + bool operator==(const VdirCollection&) const = default; + }; + + // Resolves the default XDG calendar path: $XDG_DATA_HOME/calendars or ~/.local/share/calendars. + [[nodiscard]] std::filesystem::path defaultVdirPath(); + + // Recursively discovers leaf directories containing *.ics files starting from rootPath up to maxDepth. + [[nodiscard]] std::vector + discoverVdirCollections(const std::filesystem::path& rootPath, int maxDepth = 5); + + // Reads all *.ics files in the collection and parses them within the given time window. + [[nodiscard]] std::vector loadVdirCollectionEvents( + const VdirCollection& collection, std::chrono::system_clock::time_point windowStart, + std::chrono::system_clock::time_point windowEnd, ICalParseControl& control + ); + +} // namespace calendar diff --git a/src/config/config_types.h b/src/config/config_types.h index 9c36fcf14a..2201939f11 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -1150,6 +1150,7 @@ struct CalendarConfig { std::vector calendars; // discovered collection ids; empty = all CalendarCredentialSource credentialSource = CalendarCredentialSource::SecretService; // CalDAV only std::string passwordFile; // required for file-backed CalDAV credentials + std::string path; // directory path for vdir/local accounts bool operator==(const Account&) const = default; }; diff --git a/src/config/schema/config_schema.cpp b/src/config/schema/config_schema.cpp index 1f219b3891..313603607f 100644 --- a/src/config/schema/config_schema.cpp +++ b/src/config/schema/config_schema.cpp @@ -623,8 +623,27 @@ namespace noctalia::config::schema { } ), pathStringField(&CalendarConfig::Account::passwordFile, "password_file"), + pathStringField(&CalendarConfig::Account::path, "path"), finalize([](CalendarConfig::Account& out, std::string_view parentPath, Diagnostics& diag) { + if (out.type == "vdir" || out.type == "local") { + if (out.credentialSource != CalendarCredentialSource::SecretService) { + diag.error(joinPath(parentPath, "credential_source"), "credential_source is only valid for caldav"); + } + if (!out.passwordFile.empty()) { + diag.error(joinPath(parentPath, "password_file"), "password_file is only valid for caldav"); + } + if (!out.username.empty()) { + diag.error(joinPath(parentPath, "username"), "username is only valid for caldav"); + } + if (!out.provider.empty()) { + diag.error(joinPath(parentPath, "provider"), "provider is only valid for caldav"); + } + if (!out.serverUrl.empty()) { + diag.error(joinPath(parentPath, "server_url"), "server_url is not used for vdir accounts (use path)"); + } + return; + } if (out.type == "ics") { if (out.serverUrl.empty()) { diag.error(joinPath(parentPath, "server_url"), "ics accounts require server_url (.ics file URL)"); diff --git a/src/shell/settings/settings_window_popups.cpp b/src/shell/settings/settings_window_popups.cpp index 22323b3ebf..5e68339a3c 100644 --- a/src/shell/settings/settings_window_popups.cpp +++ b/src/shell/settings/settings_window_popups.cpp @@ -104,6 +104,7 @@ namespace { CustomCalDav, Google, IcsFileURL, + Vdir, }; struct CalendarAccountDraft { @@ -116,6 +117,7 @@ namespace { CalendarCredentialSource credentialSource = CalendarCredentialSource::SecretService; std::string passwordFile; std::string serverUrl; + std::string path; std::string color; std::vector calendars; std::vector discoveredCalendars; @@ -124,6 +126,7 @@ namespace { bool passwordInvalid = false; bool passwordFileInvalid = false; bool serverUrlInvalid = false; + bool pathInvalid = false; bool credentialOperationInFlight = false; }; @@ -161,6 +164,8 @@ namespace { return "google"; case CalendarAccountProvider::IcsFileURL: return "ics"; + case CalendarAccountProvider::Vdir: + return "vdir"; } return "icloud"; } @@ -175,6 +180,8 @@ namespace { return i18n::tr("settings.calendar-accounts.provider.google"); case CalendarAccountProvider::IcsFileURL: return i18n::tr("settings.calendar-accounts.provider.ics"); + case CalendarAccountProvider::Vdir: + return i18n::tr("settings.calendar-accounts.provider.vdir"); } return i18n::tr("settings.calendar-accounts.provider.icloud"); } @@ -1018,7 +1025,12 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun auto draft = std::make_shared(); if (accountId.has_value()) { const CalendarConfig::Account* account = findCalendarAccount(cfg, *accountId); - if (account == nullptr || (account->type != "caldav" && account->type != "google" && account->type != "ics")) { + if (account == nullptr + || (account->type != "caldav" + && account->type != "google" + && account->type != "ics" + && account->type != "vdir" + && account->type != "local")) { return; } draft->creating = false; @@ -1034,6 +1046,12 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun draft->provider = CalendarAccountProvider::Google; } else if (account->type == "ics") { draft->provider = CalendarAccountProvider::IcsFileURL; + } else if (account->type == "vdir" || account->type == "local") { + draft->provider = CalendarAccountProvider::Vdir; + draft->path = account->path; + const std::string rawDiscovery = + m_config->stateString(kCalendarDiscoveryOwner, account->id + "_calendars").value_or(std::string{}); + draft->discoveredCalendars = calendar::parseCalendarSources(rawDiscovery); } else { draft->provider = account->provider == "custom" ? CalendarAccountProvider::CustomCalDav : CalendarAccountProvider::ICloud; @@ -1117,6 +1135,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun return 2; case CalendarAccountProvider::IcsFileURL: return 3; + case CalendarAccountProvider::Vdir: + return 4; } return 0; }; @@ -1128,7 +1148,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun {.label = calendarProviderTitle(CalendarAccountProvider::ICloud), .glyph = "brand-apple"}, {.label = calendarProviderTitle(CalendarAccountProvider::CustomCalDav), .glyph = "calendar-cog"}, {.label = calendarProviderTitle(CalendarAccountProvider::Google), .glyph = "brand-google"}, - {.label = calendarProviderTitle(CalendarAccountProvider::IcsFileURL), .glyph = "link"} + {.label = calendarProviderTitle(CalendarAccountProvider::IcsFileURL), .glyph = "link"}, + {.label = calendarProviderTitle(CalendarAccountProvider::Vdir), .glyph = "folder"} }, .selectedIndex = providerIndex(draft->provider), .scale = scale, @@ -1142,10 +1163,12 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun provider = CalendarAccountProvider::Google; } else if (index == 3) { provider = CalendarAccountProvider::IcsFileURL; + } else if (index == 4) { + provider = CalendarAccountProvider::Vdir; } draft->provider = provider; - if (provider == CalendarAccountProvider::Google) { + if (provider == CalendarAccountProvider::Google || provider == CalendarAccountProvider::Vdir) { draft->credentialSource = CalendarCredentialSource::SecretService; draft->passwordFile.clear(); } @@ -1153,7 +1176,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun || draft->id == "personal_icloud" || draft->id == "home_nextcloud" || draft->id == "personal_google" - || draft->id == "subscription"; + || draft->id == "subscription" + || draft->id == "local_calendar"; if (provider == CalendarAccountProvider::Google && isDefaultId) { draft->id = "personal_google"; } else if (provider == CalendarAccountProvider::CustomCalDav && isDefaultId) { @@ -1162,6 +1186,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun draft->id = "personal_icloud"; } else if (provider == CalendarAccountProvider::IcsFileURL && isDefaultId) { draft->id = "subscription"; + } else if (provider == CalendarAccountProvider::Vdir && isDefaultId) { + draft->id = "local_calendar"; } if (m_editorSheetModal != nullptr) { m_editorSheetModal->rebuildBody(); @@ -1203,7 +1229,10 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun Input* passwordInput = nullptr; Input* passwordFileInput = nullptr; Input* serverInput = nullptr; - if (draft->provider != CalendarAccountProvider::Google && draft->provider != CalendarAccountProvider::IcsFileURL) { + Input* pathInput = nullptr; + if (draft->provider != CalendarAccountProvider::Google + && draft->provider != CalendarAccountProvider::IcsFileURL + && draft->provider != CalendarAccountProvider::Vdir) { addField( body, i18n::tr("settings.calendar-accounts.credential-source-label"), ui::segmented({ @@ -1293,16 +1322,31 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun if (draft->provider == CalendarAccountProvider::IcsFileURL) { addField( body, i18n::tr("settings.calendar-accounts.ics-url-label"), - ui::input( - {.out = &serverInput, - .value = draft->serverUrl, - .placeholder = "https://example.com/calendar.ics", - .invalid = draft->serverUrlInvalid, - .onChange = [draft](const std::string& value) { - draft->serverUrl = value; - draft->serverUrlInvalid = false; - }} - ) + ui::input({ + .out = &serverInput, + .value = draft->serverUrl, + .placeholder = "https://example.com/calendar.ics", + .invalid = draft->serverUrlInvalid, + .onChange = [draft](const std::string& value) { + draft->serverUrl = value; + draft->serverUrlInvalid = false; + }, + }) + ); + } + if (draft->provider == CalendarAccountProvider::Vdir) { + addField( + body, i18n::tr("settings.calendar-accounts.vdir-path-label"), + ui::input({ + .out = &pathInput, + .value = draft->path, + .placeholder = "~/.local/share/calendars", + .invalid = draft->pathInvalid, + .onChange = [draft](const std::string& value) { + draft->path = value; + draft->pathInvalid = false; + }, + }) ); } @@ -1399,7 +1443,7 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun } const auto persistAccount = [this, draft, idInput, nameInput, usernameInput, passwordInput, passwordFileInput, - serverInput](bool closeAfter, bool connectAfter) { + serverInput, pathInput](bool closeAfter, bool connectAfter) { if (m_config == nullptr) { return; } @@ -1416,12 +1460,16 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun draft->passwordFile = trimInput(passwordFileInput); } draft->serverUrl = trimInput(serverInput); + if (pathInput != nullptr) { + draft->path = trimInput(pathInput); + } draft->idInvalid = false; draft->usernameInvalid = false; draft->passwordInvalid = false; draft->passwordFileInvalid = false; draft->serverUrlInvalid = false; + draft->pathInvalid = false; if (!validCalendarAccountId(draft->id)) { draft->idInvalid = true; @@ -1433,6 +1481,7 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun const bool caldav = draft->provider == CalendarAccountProvider::ICloud || draft->provider == CalendarAccountProvider::CustomCalDav; const bool ics = draft->provider == CalendarAccountProvider::IcsFileURL; + const bool vdir = draft->provider == CalendarAccountProvider::Vdir; if (caldav && draft->username.empty()) { draft->usernameInvalid = true; } @@ -1448,7 +1497,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun || draft->usernameInvalid || draft->passwordInvalid || draft->passwordFileInvalid - || draft->serverUrlInvalid) { + || draft->serverUrlInvalid + || draft->pathInvalid) { showTransientStatus(i18n::tr("settings.calendar-accounts.invalid"), true); return; } @@ -1464,6 +1514,8 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun type = "google"; else if (ics) type = "ics"; + else if (vdir) + type = "vdir"; overrides.push_back({{base[0], base[1], base[2], "type"}, type}); overrides.push_back({{base[0], base[1], base[2], "name"}, draft->name}); @@ -1485,6 +1537,9 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun if (draft->provider == CalendarAccountProvider::CustomCalDav || ics) { overrides.push_back({{base[0], base[1], base[2], "server_url"}, draft->serverUrl}); } + if (vdir) { + overrides.push_back({{base[0], base[1], base[2], "path"}, draft->path}); + } std::string connectActivationToken; if (connectAfter) { @@ -1498,6 +1553,9 @@ void SettingsWindow::openCalendarAccountEditor(std::optional accoun markSettingsWriteError(i18n::tr("settings.calendar-accounts.save-error")); return; } + if (m_calendarService != nullptr) { + m_calendarService->requestRefresh(); + } markSettingsWriteSuccess(closeAfter); if (connectAfter && m_calendarService != nullptr) { DeferredCall::callLater([this, accountId = draft->id, activationToken = std::move(connectActivationToken)]() { diff --git a/src/shell/settings/settings_window_scene.cpp b/src/shell/settings/settings_window_scene.cpp index 93c0aacbcb..08553bd929 100644 --- a/src/shell/settings/settings_window_scene.cpp +++ b/src/shell/settings/settings_window_scene.cpp @@ -1875,7 +1875,11 @@ void SettingsWindow::refreshSettingsRegistry(const Config& cfg) { ++it; for (const CalendarConfig::Account& account : cfg.calendar.accounts) { - if (account.type != "google" && account.type != "caldav" && account.type != "ics") { + if (account.type != "google" + && account.type != "caldav" + && account.type != "ics" + && account.type != "vdir" + && account.type != "local") { continue; } const bool credentialLocked = account.type == "google" @@ -1913,7 +1917,8 @@ void SettingsWindow::refreshSettingsRegistry(const Config& cfg) { : "edit", .variant = credentialLocked ? ButtonVariant::Secondary : ButtonVariant::Default, }, - .searchText = "calendar account edit connect authorize caldav icloud google password ics ical subscription " + .searchText = + "calendar account edit connect authorize caldav icloud google password ics ical subscription vdir local " + account.id, .visibleWhen = calendarOn, }; diff --git a/tests/vdir_reader_test.cpp b/tests/vdir_reader_test.cpp new file mode 100644 index 0000000000..378586a84d --- /dev/null +++ b/tests/vdir_reader_test.cpp @@ -0,0 +1,150 @@ +#include "calendar/vdir_reader.h" + +#include +#include +#include +#include +#include +#include + +namespace { + + using namespace std::chrono; + + bool expect(bool condition, const char* message) { + if (!condition) { + std::println(stderr, "vdir_reader_test: {}", message); + } + return condition; + } + + void writeFile(const std::filesystem::path& path, std::string_view content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out << content; + } + + constexpr std::string_view kSampleIcs1 = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Example//Test//EN\r\n" + "X-WR-CALNAME:Personal Cal\r\n" + "BEGIN:VEVENT\r\n" + "UID:evt-1@example.com\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260820T100000Z\r\n" + "DTEND:20260820T110000Z\r\n" + "SUMMARY:Meeting 1\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n"; + + constexpr std::string_view kSampleIcs2 = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Example//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:evt-2@example.com\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260821T140000Z\r\n" + "DTEND:20260821T150000Z\r\n" + "SUMMARY:Meeting 2\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n"; + + bool testNestedDiscovery() { + const auto tempDir = std::filesystem::temp_directory_path() / "noctalia_vdir_test_nested"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + + // Create structure: + // tempDir/ + // fastmail_cal/ + // uuid-1/ + // event1.ics (has X-WR-CALNAME: Personal Cal) + // uuid-2/ + // displayname ("Work Events") + // color ("#336699") + // order ("1") + // event2.ics + // .git/ + // junk.ics (should be ignored) + // temp.tmp/ + // junk.ics (should be ignored) + writeFile(tempDir / "fastmail_cal" / "uuid-1" / "event1.ics", kSampleIcs1); + writeFile(tempDir / "fastmail_cal" / "uuid-2" / "displayname", "Work Events\n"); + writeFile(tempDir / "fastmail_cal" / "uuid-2" / "color", "#336699\n"); + writeFile(tempDir / "fastmail_cal" / "uuid-2" / "order", "1\n"); + writeFile(tempDir / "fastmail_cal" / "uuid-2" / "event2.ics", kSampleIcs2); + writeFile(tempDir / "fastmail_cal" / ".git" / "junk.ics", kSampleIcs1); + writeFile(tempDir / "fastmail_cal" / "temp.tmp" / "junk.ics", kSampleIcs1); + + auto collections = calendar::discoverVdirCollections(tempDir); + + bool ok = true; + ok &= expect(collections.size() == 2, "Expected 2 discovered collections"); + + if (collections.size() == 2) { + // uuid-1 has order 0 (default), uuid-2 has order 1 + const auto& col1 = collections[0]; + const auto& col2 = collections[1]; + + ok &= expect(col1.id == "fastmail_cal/uuid-1", "col1 id matches relative path"); + ok &= expect(col1.name == "Personal Cal", "col1 extracted X-WR-CALNAME"); + ok &= expect(col1.colorHex.empty(), "col1 has empty color"); + + ok &= expect(col2.id == "fastmail_cal/uuid-2", "col2 id matches relative path"); + ok &= expect(col2.name == "Work Events", "col2 read displayname file"); + ok &= expect(col2.colorHex == "#336699", "col2 read color file"); + ok &= expect(col2.order == 1, "col2 read order file"); + + // Test loading events from col2 + calendar::ICalParseControl control; + const auto now = system_clock::now(); + auto events = calendar::loadVdirCollectionEvents(col2, now - hours{24 * 365}, now + hours{24 * 365}, control); + ok &= expect(events.size() == 1, "col2 loaded 1 event"); + if (!events.empty()) { + ok &= expect(events[0].id == "evt-2@example.com", "event id matches"); + ok &= expect(events[0].title == "Meeting 2", "event title matches"); + ok &= expect(events[0].calendarName == "Work Events", "event calendarName matches"); + ok &= expect(events[0].colorHex == "#336699", "event colorHex matches"); + } + } + + std::filesystem::remove_all(tempDir); + return ok; + } + + bool testDirectCollectionDiscovery() { + const auto tempDir = std::filesystem::temp_directory_path() / "noctalia_vdir_test_direct"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + + writeFile(tempDir / "displayname", "Single Calendar\n"); + writeFile(tempDir / "color", "#FF5500\n"); + writeFile(tempDir / "event.ics", kSampleIcs1); + + auto collections = calendar::discoverVdirCollections(tempDir); + + bool ok = true; + ok &= expect(collections.size() == 1, "Expected 1 direct collection"); + if (!collections.empty()) { + ok &= expect(collections[0].name == "Single Calendar", "Read displayname for direct collection"); + ok &= expect(collections[0].colorHex == "#FF5500", "Read color for direct collection"); + ok &= expect(collections[0].path == tempDir, "Path matches tempDir"); + } + + std::filesystem::remove_all(tempDir); + return ok; + } + +} // namespace + +int main() { + bool ok = true; + ok &= testNestedDiscovery(); + ok &= testDirectCollectionDiscovery(); + + if (ok) { + std::println("vdir_reader_test passed"); + return 0; + } + return 1; +}