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
2 changes: 2 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ _noctalia_sources = files(
'src/calendar/calendar_credential_store.cpp',
'src/calendar/calendar_discovery_state.cpp',
'src/calendar/calendar_service.cpp',
'src/calendar/event_link.cpp',
'src/calendar/google_client.cpp',
'src/calendar/google_oauth.cpp',
'src/calendar/ical_parser.cpp',
Expand Down Expand Up @@ -1121,6 +1122,7 @@ if build_tests
'desktop_entry_launch',
'disk_mounts',
'dock_pinned_apps',
'event_link',
'fcitx_status',
'google_client_calendar_list',
'hook_manager',
Expand Down
7 changes: 7 additions & 0 deletions src/calendar/calendar_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "calendar/caldav_discovery.h"
#include "calendar/calendar_cache.h"
#include "calendar/calendar_discovery_state.h"
#include "calendar/event_link.h"
#include "config/config_service.h"
#include "core/log.h"
#include "i18n/i18n.h"
Expand Down Expand Up @@ -1028,6 +1029,11 @@ bool CalendarService::parseCache(std::span<const std::uint8_t> contents) {
event.calendarName = item.value("calendar", std::string{});
event.colorHex = item.value("color", std::string{});
event.location = item.value("location", std::string{});
event.url = item.value("url", std::string{});
if (event.url.empty()) {
// Caches written before events carried a link still hold the LOCATION they were derived from.
event.url = calendar::resolveEventLink(event.location, {});
}
event.start = fromUnix(item.value("start", std::int64_t{0}));
event.end = fromUnix(item.value("end", std::int64_t{0}));
event.allDay = item.value("all_day", false);
Expand Down Expand Up @@ -1060,6 +1066,7 @@ void CalendarService::saveCache() {
{"calendar", event.calendarName},
{"color", event.colorHex},
{"location", event.location},
{"url", event.url},
{"start", toUnix(event.start)},
{"end", toUnix(event.end)},
{"all_day", event.allDay},
Expand Down
1 change: 1 addition & 0 deletions src/calendar/calendar_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct CalendarEvent {
std::string calendarName; // owning calendar's display name
std::string colorHex; // owning calendar's color (e.g. "#3367d6"), empty when unknown
std::string location; // LOCATION, optional
std::string url; // resolved http(s) link from LOCATION/URL, empty when the event has none
std::chrono::system_clock::time_point start;
std::chrono::system_clock::time_point end;
bool allDay = false;
Expand Down
109 changes: 109 additions & 0 deletions src/calendar/event_link.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#include "calendar/event_link.h"

#include "util/string_utils.h"

#include <array>
#include <cctype>
#include <cstddef>

namespace calendar {
namespace {
constexpr std::size_t kMaxLinkLength = 2048;
constexpr std::string_view kWhitespace = " \t\r\n\v\f";

bool equalsIgnoreCase(std::string_view a, std::string_view b) {
if (a.size() != b.size()) {
return false;
}
for (std::size_t i = 0; i < a.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) {
return false;
}
}
return true;
}

// Length of the http(s) scheme prefix at the start of `text`, or 0 when it starts with neither.
std::size_t schemePrefixLength(std::string_view text) {
for (const std::string_view scheme : std::array<std::string_view, 2>{"https://", "http://"}) {
if (text.size() > scheme.size() && equalsIgnoreCase(text.substr(0, scheme.size()), scheme)) {
return scheme.size();
}
}
return 0;
}

bool hasHost(std::string_view link, std::size_t schemeLength) {
const std::string_view rest = link.substr(schemeLength);
return rest.find_first_of("/?#") != 0;
}

bool isValidLink(std::string_view link) {
if (link.empty() || link.size() > kMaxLinkLength) {
return false;
}
const std::size_t schemeLength = schemePrefixLength(link);
if (schemeLength == 0 || !hasHost(link, schemeLength)) {
return false;
}
for (const char c : link) {
const auto byte = static_cast<unsigned char>(c);
if (byte <= 0x20 || byte == 0x7f) {
return false;
}
}
return true;
}

// Drop sentence punctuation and unbalanced closing wrappers a link picks up when it is embedded
// in prose, e.g. "Join: <https://meet.example/x>." or "(https://meet.example/x)".
std::string_view trimTrailingPunctuation(std::string_view candidate) {
while (!candidate.empty()) {
const char last = candidate.back();
const bool sentencePunctuation =
last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?';
const bool unbalancedWrapper = (last == ')' && !candidate.contains('('))
|| (last == ']' && !candidate.contains('['))
|| (last == '}' && !candidate.contains('{'))
|| last == '>'
|| last == '"'
|| last == '\'';
if (!sentencePunctuation && !unbalancedWrapper) {
break;
}
candidate.remove_suffix(1);
}
return candidate;
}

std::string extractEmbeddedLink(std::string_view text) {
for (std::size_t i = 0; i < text.size(); ++i) {
if (schemePrefixLength(text.substr(i)) == 0) {
continue;
}
// Only match at a token boundary, so "myhttp://x" is not read as a link.
if (i > 0 && std::isalnum(static_cast<unsigned char>(text[i - 1])) != 0) {
continue;
}
std::string_view candidate = text.substr(i);
if (const std::size_t end = candidate.find_first_of(kWhitespace); end != std::string_view::npos) {
candidate = candidate.substr(0, end);
}
candidate = trimTrailingPunctuation(candidate);
if (isValidLink(candidate)) {
return std::string(candidate);
}
}
return {};
}
} // namespace

std::string resolveEventLink(std::string_view location, std::string_view urlProperty) {
if (std::string link = extractEmbeddedLink(location); !link.empty()) {
return link;
}
const std::string_view trimmed = StringUtils::trimRightView(StringUtils::trimLeftView(urlProperty));
return isValidLink(trimmed) ? std::string(trimmed) : std::string{};
}

} // namespace calendar
16 changes: 16 additions & 0 deletions src/calendar/event_link.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#pragma once

#include <string>
#include <string_view>

namespace calendar {

// Resolve the clickable link for an event. Meeting links are conventionally written into LOCATION
// by Google and Outlook, while the RFC 5545 URL property more often points at an event page, so a
// link embedded in `location` wins over `urlProperty`.
// Only http(s) links survive: the value comes from a remote calendar server and is handed to
// xdg-open, so schemes such as file: or data: must never reach a handler. Returns empty when
// neither input yields a valid link.
[[nodiscard]] std::string resolveEventLink(std::string_view location, std::string_view urlProperty);

} // namespace calendar
2 changes: 2 additions & 0 deletions src/calendar/google_client.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "calendar/google_client.h"

#include "calendar/event_link.h"
#include "calendar/google_calendar_list.h"
#include "core/log.h"
#include "net/http_client.h"
Expand Down Expand Up @@ -107,6 +108,7 @@ namespace calendar {
event.id = item.value("id", std::string{});
event.title = item.value("summary", std::string{});
event.location = item.value("location", std::string{});
event.url = resolveEventLink(event.location, item.value("hangoutLink", std::string{}));
event.calendarName = meta.name;
event.colorHex = meta.color;

Expand Down
8 changes: 8 additions & 0 deletions src/calendar/ical_parser.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "calendar/ical_parser.h"

#include "calendar/event_link.h"
#include "core/log.h"

#include <algorithm>
Expand Down Expand Up @@ -201,6 +202,13 @@ namespace calendar {
if (const char* location = icalcomponent_get_location(component); location != nullptr) {
event.location = location;
}
std::string urlProperty;
if (icalproperty* url = icalcomponent_get_first_property(component, ICAL_URL_PROPERTY); url != nullptr) {
if (const char* value = icalproperty_get_url(url); value != nullptr) {
urlProperty = value;
}
}
event.url = resolveEventLink(event.location, urlProperty);

const icaltimetype start = icalcomponent_get_dtstart(component);
const icaltimetype end = icalcomponent_get_dtend(component);
Expand Down
95 changes: 85 additions & 10 deletions src/shell/control_center/tabs/calendar_tab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#include "calendar/calendar_service.h"
#include "config/config_service.h"
#include "core/ui_phase.h"
#include "cursor-shape-v1-client-protocol.h"
#include "i18n/i18n.h"
#include "net/url_open.h"
#include "render/animation/animation.h"
#include "render/animation/animation_manager.h"
#include "render/core/color.h"
Expand Down Expand Up @@ -361,6 +363,7 @@ void CalendarTab::doLayout(Renderer& renderer, float contentWidth, float bodyHei

if (m_monthSlideAnimId != 0 && !m_startMonthSlideIn) {
m_rootLayout->layout(renderer);
layoutEventLinkOverlays();
return;
}

Expand Down Expand Up @@ -390,6 +393,7 @@ void CalendarTab::doLayout(Renderer& renderer, float contentWidth, float bodyHei
beginSlideIn();
}
m_rootLayout->layout(renderer);
layoutEventLinkOverlays();
}

void CalendarTab::doUpdate(Renderer& renderer) {
Expand Down Expand Up @@ -437,6 +441,7 @@ void CalendarTab::onClose() {
m_eventsCard = nullptr;
m_eventsTitle = nullptr;
m_eventsScroll = nullptr;
m_eventLinkOverlays.clear();
m_selectedYear = std::numeric_limits<int>::min();
m_selectedMonth = -1;
m_selectedDay = -1;
Expand Down Expand Up @@ -922,6 +927,7 @@ void CalendarTab::rebuildEventList(float scale) {
// Start-aligned rows lay out at their rounded natural width, which can re-wrap a short title
// during arrange while the row reserved only a single line of height — overflowing the next event.
content->setAlign(FlexAlign::Stretch);
m_eventLinkOverlays.clear();
while (!content->children().empty()) {
content->removeChild(content->children().front().get());
}
Expand Down Expand Up @@ -969,7 +975,13 @@ void CalendarTab::rebuildEventList(float scale) {
return;
}

const float linkGlyphSize = Style::fontSizeCaption * scale;
const float linkGlyphGap = Style::spaceXs * scale;

for (const CalendarEvent* event : dayEvents) {
const bool hasLink = !event->url.empty();
const float timeMaxWidth =
hasLink ? std::max(40.0F, textMaxWidth - linkGlyphSize - linkGlyphGap - dotWidth - rowGap) : textMaxWidth;
std::string timeText;
if (event->allDay) {
timeText = i18n::tr("control-center.calendar.all-day");
Expand All @@ -989,6 +1001,32 @@ void CalendarTab::rebuildEventList(float scale) {

Label* titleLabel = nullptr;
Label* timeLabel = nullptr;
auto time = ui::label({
.out = &timeLabel,
.text = timeText,
.fontSize = Style::fontSizeCaption * scale,
.color = colorSpecFromRole(ColorRole::OnSurfaceVariant),
.maxLines = 1,
});
// The link marker trails the time line, pushed to the row's right edge.
std::unique_ptr<Node> timeLine = std::move(time);
if (hasLink) {
timeLine = ui::row(
{.align = FlexAlign::Center,
.justify = FlexJustify::SpaceBetween,
.gap = linkGlyphGap,
.fillWidth = true,
// Inset the marker by the same distance the text sits from the row's left edge.
.configure = [inset = dotWidth + rowGap](Flex& line) { line.setPadding(0.0F, inset, 0.0F, 0.0F); }},
std::move(timeLine),
ui::glyph({
.glyph = "external-link",
.glyphSize = linkGlyphSize,
.color = colorSpecFromRole(ColorRole::OnSurfaceVariant),
.flexGrow = 0.0F,
})
);
}
auto details = ui::column(
{.align = FlexAlign::Start, .gap = Style::spaceXs * 0.5f * scale, .flexGrow = 1.0f},
ui::label({
Expand All @@ -998,23 +1036,60 @@ void CalendarTab::rebuildEventList(float scale) {
.color = colorSpecFromRole(ColorRole::OnSurface),
.maxLines = 3,
}),
ui::label({
.out = &timeLabel,
.text = timeText,
.fontSize = Style::fontSizeCaption * scale,
.color = colorSpecFromRole(ColorRole::OnSurfaceVariant),
.maxLines = 1,
})
std::move(timeLine)
);
if (titleLabel != nullptr) {
titleLabel->setMaxWidth(textMaxWidth);
}
if (timeLabel != nullptr) {
timeLabel->setMaxWidth(textMaxWidth);
timeLabel->setMaxWidth(timeMaxWidth);
}

auto eventRow = ui::row({.align = FlexAlign::Stretch, .gap = rowGap}, std::move(dot), std::move(details));
content->addChild(std::move(eventRow));
Flex* eventRow = nullptr;
auto eventRowNode =
ui::row({.out = &eventRow, .align = FlexAlign::Stretch, .gap = rowGap}, std::move(dot), std::move(details));
if (hasLink && eventRow != nullptr) {
addEventLinkOverlay(*eventRow, event->url, scale);
}
content->addChild(std::move(eventRowNode));
}
}

// InputArea does not lay out children, so the clickable region is an overlay sized to the row in
// layoutEventLinkOverlays() once the row itself has been arranged.
void CalendarTab::addEventLinkOverlay(Flex& row, const std::string& url, float scale) {
auto area = ui::inputArea({});
area->setParticipatesInLayout(false);
area->setZIndex(1);
area->setCursorShape(WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_POINTER);
area->setTooltip(url);

Flex* rowPtr = &row;
const float radius = Style::radiusSm * scale;
const auto setHovered = [rowPtr, radius](bool hovered) {
if (hovered) {
rowPtr->setRadius(radius);
rowPtr->setFill(colorSpecFromRole(ColorRole::Hover));
} else {
rowPtr->clearFill();
}
PanelManager::instance().requestRedraw();
};
area->setOnEnter([setHovered](const InputArea::PointerData&) { setHovered(true); });
area->setOnLeave([setHovered]() { setHovered(false); });
area->setOnClick([url](const InputArea::PointerData&) { (void)net::openInBrowser(url); });

m_eventLinkOverlays.push_back({.row = rowPtr, .area = area.get()});
row.addChild(std::move(area));
}

void CalendarTab::layoutEventLinkOverlays() {
for (const auto& [row, area] : m_eventLinkOverlays) {
if (row == nullptr || area == nullptr) {
continue;
}
area->setPosition(0.0F, 0.0F);
area->setSize(row->width(), row->height());
}
}

Expand Down
Loading