Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -1206,6 +1207,7 @@ if build_tests
'widget_action',
'widget_definition',
'wallpaper_shuffle_state',
'vdir_reader',
'workspace_alert_service',
]

Expand Down
6 changes: 4 additions & 2 deletions src/calendar/calendar_poll_source.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<pollfd>& /*fds*/, std::size_t /*startIdx*/) override { m_calendar.tick(); }
void dispatch(const std::vector<pollfd>& fds, std::size_t startIdx) override {
m_calendar.dispatchPoll(fds, startIdx);
}

protected:
void doAddPollFds(std::vector<pollfd>& /*fds*/) override {}
void doAddPollFds(std::vector<pollfd>& fds) override { m_calendar.addPollFds(fds); }

private:
CalendarService& m_calendar;
Expand Down
123 changes: 123 additions & 0 deletions src/calendar/calendar_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -109,6 +110,7 @@ void CalendarService::initialize() {
m_initialized = true;
syncCachePersistence();
initializeCredentials();
updateVdirWatches();
}

void CalendarService::syncCachePersistence() {
Expand Down Expand Up @@ -290,6 +292,7 @@ void CalendarService::onConfigReload() {
return;
}
m_activeConfig = next;
updateVdirWatches();
std::unordered_set<std::string> activeAccountIds;
activeAccountIds.reserve(m_activeConfig.accounts.size());
for (const CalendarConfig::Account& account : m_activeConfig.accounts) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand All @@ -353,6 +364,25 @@ void CalendarService::tick() {
}
}

void CalendarService::addPollFds(std::vector<pollfd>& fds) {
if (m_vdirInotify.fd() >= 0) {
fds.push_back({.fd = m_vdirInotify.fd(), .events = POLLIN, .revents = 0});
}
}

void CalendarService::dispatchPoll(const std::vector<pollfd>& 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<std::int32_t>(1, m_activeConfig.refreshMinutes);
m_nextRefreshAt = std::chrono::steady_clock::now() + std::chrono::minutes{minutes};
Expand Down Expand Up @@ -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, {});
Expand Down Expand Up @@ -626,6 +658,97 @@ void CalendarService::fetchIcs(const CalendarConfig::Account& account) {
});
}

void CalendarService::updateVdirWatches() {
if (!m_activeConfig.enabled) {
return;
}

std::set<std::filesystem::path> 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<CalendarSource> 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<std::string> 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<CalendarEvent> 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<void(bool, std::string)> cb) {
m_credentials.lookupRefreshToken(
accountId,
Expand Down
11 changes: 11 additions & 0 deletions src/calendar/calendar_service.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chrono>
Expand All @@ -14,6 +15,8 @@
#include <functional>
#include <map>
#include <optional>
#include <poll.h>
#include <set>
#include <span>
#include <string>
#include <vector>
Expand Down Expand Up @@ -71,6 +74,8 @@ class CalendarService {

[[nodiscard]] int pollTimeoutMs() const;
void tick();
void addPollFds(std::vector<pollfd>& fds);
void dispatchPoll(const std::vector<pollfd>& fds, std::size_t startIdx);

[[nodiscard]] bool enabled() const noexcept { return m_activeConfig.enabled; }
[[nodiscard]] bool hasData() const noexcept { return m_snapshot.valid; }
Expand Down Expand Up @@ -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<void(bool ok, std::string accessToken)> cb);
void googleFetchWithToken(const std::string& accountId, const std::string& accessToken, bool allowRefreshRetry);
Expand Down Expand Up @@ -182,4 +189,8 @@ class CalendarService {
bool m_googleCredentialLockedNotificationShown = false;
ConnectFlow m_connect;
calendar::CalDavClient m_caldav;
Inotify m_vdirInotify;
std::set<std::filesystem::path> m_watchedVdirPaths;
std::chrono::steady_clock::time_point m_vdirDebounceUntil;
bool m_vdirDebouncePending = false;
};
Loading