diff options
Diffstat (limited to 'src/calendarwindow.cpp')
| -rw-r--r-- | src/calendarwindow.cpp | 732 |
1 files changed, 732 insertions, 0 deletions
diff --git a/src/calendarwindow.cpp b/src/calendarwindow.cpp new file mode 100644 index 0000000..10caac9 --- /dev/null +++ b/src/calendarwindow.cpp @@ -0,0 +1,732 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "calendarwindow.h" +#include "agendaview.h" +#include "calendarstore.h" +#include "calendarsync.h" +#include "calendarwriter.h" +#include "eventpane.h" +#include "monthview.h" + +#include <QAction> +#include <QButtonGroup> +#include <QCloseEvent> +#include <QComboBox> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QHBoxLayout> +#include <QLabel> +#include <QLocale> +#include <QMenu> +#include <QMenuBar> +#include <QMessageBox> +#include <QPainter> +#include <QPixmap> +#include <QPushButton> +#include <QSettings> +#include <QSpinBox> +#include <QSplitter> +#include <QStackedWidget> +#include <QStatusBar> +#include <QTimeZone> +#include <QToolBar> +#include <QToolButton> +#include <QUndoCommand> + +namespace { + +/// One undoable write. The write itself happened before the push, so the +/// first redo() (which QUndoStack::push calls) does nothing. Undo restores +/// exactly the bytes the write replaced, expecting on disk exactly the bytes +/// it wrote: a sync in between makes it refuse, and the command is dropped. +class FileCommand : public QUndoCommand +{ +public: + FileCommand(const QString &text, const QList<CalendarWindow::Change> &changes, + CalendarWindow *window) + : QUndoCommand(text), m_changes(changes), m_window(window) {} + + void undo() override + { + if (!m_window->applyChanges(m_changes, true)) + setObsolete(true); + } + + void redo() override + { + if (m_first) { + m_first = false; + return; + } + if (!m_window->applyChanges(m_changes, false)) + setObsolete(true); + } + +private: + QList<CalendarWindow::Change> m_changes; + CalendarWindow *m_window; + bool m_first = true; +}; + +QIcon swatch(const QColor &colour) +{ + QPixmap pixmap(12, 12); + pixmap.fill(Qt::transparent); + QPainter(&pixmap).fillRect(pixmap.rect(), colour); + return QIcon(pixmap); +} + +QByteArray readFile(const QString &path) +{ + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); +} + +} // namespace + +CalendarWindow::CalendarWindow(const Config &config, const QStringList &ownAddresses, + const QString &uiStatePath, QWidget *parent) + : QMainWindow(parent), m_config(config), m_ownAddresses(ownAddresses), + m_uiStatePath(uiStatePath), + m_year(QDate::currentDate().year()), m_month(QDate::currentDate().month()) +{ + setWindowTitle(tr("Calendar")); + m_sync = new CalendarSync(m_config.calendarSyncCommand(), + m_config.calendarSyncDelayMs(), this); + connect(m_sync, &CalendarSync::started, this, [this]() { + status(tr("Syncing calendars...")); + m_checking.insert(m_written); // insert(QHash) merges + m_written.clear(); + }); + connect(m_sync, &CalendarSync::finished, this, [this](bool ok, const QString &output) { + if (!ok) { + QMessageBox::warning(this, tr("Calendar sync failed"), output); + status(tr("Calendar sync failed.")); + } + checkAfterSync(); + }); + + m_reloadTimer.setSingleShot(true); + m_reloadTimer.setInterval(500); + connect(&m_reloadTimer, &QTimer::timeout, this, &CalendarWindow::reload); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, + [this]() { m_reloadTimer.start(); }); + + buildUi(); + buildActions(); + + QSettings state(m_uiStatePath, QSettings::IniFormat); + state.beginGroup(QStringLiteral("calendar")); + restoreGeometry(state.value(QStringLiteral("geometry")).toByteArray()); + const bool agenda = state.value(QStringLiteral("agenda"), false).toBool(); + const QString collectionDir = state.value(QStringLiteral("collection")).toString(); + state.endGroup(); + + reload(); + const int index = m_collectionBox->findData(collectionDir); + if (index >= 0) + m_collectionBox->setCurrentIndex(index); + (agenda ? m_agendaButton : m_monthButton)->click(); +} + +CalendarWindow::~CalendarWindow() = default; + +void CalendarWindow::buildUi() +{ + auto *toolbar = addToolBar(tr("Calendar")); + toolbar->setObjectName(QStringLiteral("calendarToolbar")); + toolbar->setMovable(false); + + m_collectionBox = new QComboBox(this); + m_collectionBox->setObjectName(QStringLiteral("collectionBox")); + toolbar->addWidget(m_collectionBox); + toolbar->addSeparator(); + + auto addButton = [&](const QString &text, auto slot) { + auto *b = new QToolButton(this); + b->setText(text); + connect(b, &QToolButton::clicked, this, slot); + toolbar->addWidget(b); + return b; + }; + addButton(QStringLiteral("‹"), [this]() { + const QDate d = QDate(m_year, m_month, 1).addMonths(-1); + showMonth(d.year(), d.month()); + })->setToolTip(tr("Previous month")); + addButton(tr("Today"), [this]() { + showMonth(QDate::currentDate().year(), QDate::currentDate().month()); + m_agendaView->scrollToDay(QDate::currentDate()); + })->setToolTip(tr("Go to today")); + addButton(QStringLiteral("›"), [this]() { + const QDate d = QDate(m_year, m_month, 1).addMonths(1); + showMonth(d.year(), d.month()); + })->setToolTip(tr("Next month")); + + m_monthBox = new QComboBox(this); + for (int m = 1; m <= 12; ++m) + m_monthBox->addItem(QLocale().standaloneMonthName(m), m); + toolbar->addWidget(m_monthBox); + m_yearSpin = new QSpinBox(this); + m_yearSpin->setRange(1900, 2200); + toolbar->addWidget(m_yearSpin); + connect(m_monthBox, &QComboBox::activated, this, + [this](int index) { showMonth(m_year, index + 1); }); + connect(m_yearSpin, &QSpinBox::valueChanged, this, [this](int year) { + if (year != m_year) + showMonth(year, m_month); + }); + + auto *spacer = new QWidget(this); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + toolbar->addWidget(spacer); + + m_monthButton = new QToolButton(this); + m_monthButton->setText(tr("Month")); + m_monthButton->setCheckable(true); + m_agendaButton = new QToolButton(this); + m_agendaButton->setText(tr("Agenda")); + m_agendaButton->setCheckable(true); + auto *group = new QButtonGroup(this); + group->addButton(m_monthButton, 0); + group->addButton(m_agendaButton, 1); + toolbar->addWidget(m_monthButton); + toolbar->addWidget(m_agendaButton); + + auto *splitter = new QSplitter(this); + m_views = new QStackedWidget(splitter); + m_monthView = new MonthView(m_views); + m_agendaView = new AgendaView(m_views); + m_views->addWidget(m_monthView); + m_views->addWidget(m_agendaView); + m_pane = new EventPane(splitter); + splitter->addWidget(m_views); + splitter->addWidget(m_pane); + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 1); + setCentralWidget(splitter); + + m_dataWarning = new QLabel(this); + statusBar()->addPermanentWidget(m_dataWarning); + + connect(group, &QButtonGroup::idClicked, m_views, &QStackedWidget::setCurrentIndex); + connect(m_collectionBox, &QComboBox::currentIndexChanged, this, &CalendarWindow::rebuildItems); + connect(m_monthView, &MonthView::itemClicked, this, &CalendarWindow::select); + connect(m_agendaView, &AgendaView::itemClicked, this, &CalendarWindow::select); + connect(m_monthView, &MonthView::dayDoubleClicked, this, &CalendarWindow::startNew); + connect(m_monthView, &MonthView::moreClicked, this, [this](const QDate &day) { + m_agendaButton->click(); + m_agendaView->scrollToDay(day); + }); + connect(m_pane, &EventPane::editRequested, this, &CalendarWindow::startEdit); + connect(m_pane, &EventPane::deleteRequested, this, &CalendarWindow::deleteSelected); + connect(m_pane, &EventPane::saveRequested, this, &CalendarWindow::save); + connect(m_pane, &EventPane::cancelRequested, this, &CalendarWindow::cancelEdit); +} + +void CalendarWindow::buildActions() +{ + auto make = [this](const QString &name, const QString &text, const QKeySequence &key, + QWidget *scope, auto slot) { + auto *a = new QAction(text, this); + a->setObjectName(name); + a->setShortcut(key); + if (scope) { + a->setShortcutContext(Qt::WidgetWithChildrenShortcut); + scope->addAction(a); + } else { + addAction(a); + } + connect(a, &QAction::triggered, this, slot); + return a; + }; + + QAction *newEvent = make(QStringLiteral("newEvent"), tr("&New event"), + QKeySequence(Qt::CTRL | Qt::Key_N), nullptr, + [this]() { startNew(defaultNewDay()); }); + QAction *close = make(QStringLiteral("closeCalendar"), tr("&Close"), + QKeySequence(Qt::CTRL | Qt::Key_W), nullptr, [this]() { this->close(); }); + QAction *edit = make(QStringLiteral("editEvent"), tr("&Edit event"), + QKeySequence(Qt::CTRL | Qt::Key_E), nullptr, &CalendarWindow::startEdit); + QAction *del = make(QStringLiteral("deleteEvent"), tr("&Delete event"), + QKeySequence(Qt::Key_Delete), m_views, &CalendarWindow::deleteSelected); + QAction *cancel = make(QStringLiteral("cancelEdit"), tr("Cancel &editing"), + QKeySequence(Qt::Key_Escape), m_pane, &CalendarWindow::cancelEdit); + + QAction *undo = m_undo.createUndoAction(this, tr("&Undo")); + undo->setShortcut(QKeySequence::Undo); + undo->setShortcutContext(Qt::WidgetWithChildrenShortcut); + m_views->addAction(undo); + QAction *redo = m_undo.createRedoAction(this, tr("&Redo")); + redo->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z)); + redo->setShortcutContext(Qt::WidgetWithChildrenShortcut); + m_views->addAction(redo); + + QAction *prev = make(QStringLiteral("previousMonth"), tr("&Previous month"), + QKeySequence(Qt::Key_PageUp), m_views, [this]() { + const QDate d = QDate(m_year, m_month, 1).addMonths(-1); + showMonth(d.year(), d.month()); + }); + QAction *next = make(QStringLiteral("nextMonth"), tr("&Next month"), + QKeySequence(Qt::Key_PageDown), m_views, [this]() { + const QDate d = QDate(m_year, m_month, 1).addMonths(1); + showMonth(d.year(), d.month()); + }); + QAction *today = make(QStringLiteral("today"), tr("&Today"), + QKeySequence(Qt::Key_Home), m_views, [this]() { + showMonth(QDate::currentDate().year(), QDate::currentDate().month()); + }); + QAction *month = make(QStringLiteral("monthView"), tr("&Month"), {}, nullptr, + [this]() { m_monthButton->click(); }); + QAction *agenda = make(QStringLiteral("agendaView"), tr("&Agenda"), {}, nullptr, + [this]() { m_agendaButton->click(); }); + + QMenu *file = menuBar()->addMenu(tr("&File")); + file->addAction(newEvent); + file->addSeparator(); + file->addAction(close); + QMenu *editMenu = menuBar()->addMenu(tr("&Edit")); + editMenu->addAction(undo); + editMenu->addAction(redo); + editMenu->addSeparator(); + editMenu->addAction(edit); + editMenu->addAction(del); + editMenu->addAction(cancel); + QMenu *view = menuBar()->addMenu(tr("&View")); + view->addAction(month); + view->addAction(agenda); + view->addSeparator(); + view->addAction(prev); + view->addAction(next); + view->addAction(today); +} + +/// Where + New puts an event: today when today is in the shown month, +/// otherwise the month's first day, so the new event is on screen. +QDate CalendarWindow::defaultNewDay() const +{ + const QDate first(m_year, m_month, 1); + const QDate today = QDate::currentDate(); + return today >= first && today < first.addMonths(1) ? today : first; +} + +void CalendarWindow::showMonth(int year, int month) +{ + m_year = year; + m_month = month; + m_monthView->setMonth(year, month); + refreshToolbar(); + rebuildItems(); +} + +void CalendarWindow::refreshToolbar() +{ + const QSignalBlocker a(m_monthBox), b(m_yearSpin); + m_monthBox->setCurrentIndex(m_month - 1); + m_yearSpin->setValue(m_year); +} + +void CalendarWindow::reload() +{ + m_data = CalendarStore::load(m_config.calendarsDir()); + + if (!m_watcher.directories().isEmpty()) + m_watcher.removePaths(m_watcher.directories()); + QStringList dirs{ m_config.calendarsDir() }; + for (const CalCollection &c : m_data.collections) + dirs << c.path; + m_watcher.addPaths(dirs); + + const QString current = m_collectionBox->currentData().toString(); + { + const QSignalBlocker block(m_collectionBox); + m_collectionBox->clear(); + m_collectionBox->addItem(tr("All calendars"), QString()); + for (const CalCollection &c : m_data.collections) + m_collectionBox->addItem(swatch(c.color), c.displayName, c.dir); + m_collectionBox->setCurrentIndex(qMax(0, m_collectionBox->findData(current))); + } + + QStringList warnings; + if (m_data.unparsable) + warnings << tr("%n file(s) could not be read", nullptr, m_data.unparsable); + if (m_data.unknownZones) + warnings << tr("%n event(s) in an unknown time zone, shown in local time", nullptr, + m_data.unknownZones); + m_dataWarning->setText(warnings.join(QStringLiteral("; "))); + rebuildItems(); +} + +void CalendarWindow::rebuildItems() +{ + const QString filter = m_collectionBox->currentData().toString(); + const QDateTime from(m_monthView->firstDay(), QTime(0, 0)); + const QDateTime to(m_monthView->lastDay(), QTime(0, 0)); + m_items.clear(); + for (const Occurrence &o : CalendarStore::occurrences(m_data.events, from, to)) { + const CalEvent &e = m_data.events[o.eventIndex]; + if (!filter.isEmpty() && e.collectionDir != filter) + continue; + const CalCollection *c = collection(e.collectionDir); + QString title = o.isOverride ? e.overrides[o.overrideIndex].summary : e.summary; + if (title.isEmpty()) + title = tr("(no title)"); + m_items.append({ o, title, c ? c->color : QColor(Qt::gray) }); + } + m_monthView->setItems(m_items); + + // The agenda covers the month the toolbar names, not the grid's + // spill-over. Out-of-month items become blanks, which AgendaView skips, + // so a row's index is the same index the month grid reports. + // ponytail: blanks rather than an index map; add the map if the agenda + // ever needs a range of its own. + const QDate first(m_year, m_month, 1); + QList<CalendarItem> agenda; + for (const CalendarItem &item : m_items) { + const QDate d = item.occurrence.start.toLocalTime().date(); + agenda.append(d >= first && d < first.addMonths(1) ? item : CalendarItem{}); + } + m_agendaView->setItems(agenda); + + // Re-find the selection by identity: a reload renumbers everything. + m_selected = -1; + for (int i = 0; i < m_items.size(); ++i) { + const Occurrence &o = m_items[i].occurrence; + if (m_data.events[o.eventIndex].uid == m_selectedUid + && (!m_selectedStart.isValid() || o.start == m_selectedStart)) { + m_selected = i; + break; + } + } + m_monthView->setSelected(m_selected); + m_agendaView->setSelected(m_selected); + if (!m_pane->isEditing()) { + if (m_selected >= 0) { + const Occurrence &o = m_items[m_selected].occurrence; + const CalEvent &e = m_data.events[o.eventIndex]; + const CalCollection *c = collection(e.collectionDir); + m_pane->showDetails(e, c ? *c : CalCollection{}, o, + c && CalendarStore::isEditable(e, *c, m_ownAddresses)); + } else { + m_pane->showNothing(); + } + } +} + +void CalendarWindow::select(int item) +{ + if (m_pane->isEditing()) { + status(tr("Save or cancel the event being edited first.")); + return; + } + if (item < 0 || item >= m_items.size()) + return; + const Occurrence &o = m_items[item].occurrence; + m_selectedUid = m_data.events[o.eventIndex].uid; + m_selectedStart = o.start; + rebuildItems(); +} + +bool CalendarWindow::selectEvent(const QString &uid, const QDateTime &start) +{ + if (m_pane->isEditing()) + return false; + for (int i = 0; i < m_items.size(); ++i) { + const Occurrence &o = m_items[i].occurrence; + if (m_data.events[o.eventIndex].uid == uid && (!start.isValid() || o.start == start)) { + select(i); + return true; + } + } + return false; +} + +QString CalendarWindow::selectedUid() const +{ + return m_selected >= 0 ? m_data.events[m_items[m_selected].occurrence.eventIndex].uid : QString(); +} + +bool CalendarWindow::isEditing() const { return m_pane->isEditing(); } + +const CalCollection *CalendarWindow::collection(const QString &dir) const +{ + for (const CalCollection &c : m_data.collections) + if (c.dir == dir) + return &c; + return nullptr; +} + +QList<CalCollection> CalendarWindow::writableCollections() const +{ + QList<CalCollection> result; + for (const CalCollection &c : m_data.collections) + if (!c.readOnly) + result.append(c); + return result; +} + +QString CalendarWindow::defaultCollection() const +{ + const QList<CalCollection> writable = writableCollections(); + for (const CalCollection &c : writable) + if (c.dir == m_config.defaultCalendar()) + return c.dir; + return writable.isEmpty() ? QString() : writable.first().dir; +} + +void CalendarWindow::startEdit() +{ + if (m_pane->isEditing() || m_selected < 0) + return; + const Occurrence &o = m_items[m_selected].occurrence; + const CalEvent &e = m_data.events[o.eventIndex]; + const CalCollection *c = collection(e.collectionDir); + if (!c || !CalendarStore::isEditable(e, *c, m_ownAddresses)) + return; + + // A repeating event asks its scope when Edit is pressed, never after. + m_editWholeSeries = true; + m_editRecurrenceId = {}; + if (e.repeat.freq != RepeatRule::Freq::None || e.repeat.custom) { + QMessageBox box(QMessageBox::Question, tr("Edit a repeating event"), + tr("Edit only this occurrence, or every occurrence?"), + QMessageBox::Cancel, this); + QPushButton *one = box.addButton(tr("This occurrence"), QMessageBox::AcceptRole); + QPushButton *all = box.addButton(tr("All occurrences"), QMessageBox::AcceptRole); + box.exec(); + if (box.clickedButton() != one && box.clickedButton() != all) + return; + m_editWholeSeries = box.clickedButton() == all; + m_editRecurrenceId = o.recurrenceId; + } + + EventEdit initial; + const CalOverride *ov = o.isOverride ? &e.overrides[o.overrideIndex] : nullptr; + initial.summary = ov ? ov->summary : e.summary; + initial.location = ov ? ov->location : e.location; + initial.description = ov ? ov->description : e.description; + initial.allDay = m_editWholeSeries ? e.allDay : o.allDay; + initial.start = m_editWholeSeries ? e.start : o.start; + initial.end = m_editWholeSeries ? e.end : o.end; + initial.repeat = e.repeat; + initial.collectionDir = e.collectionDir; + + QStringList people; + for (const CalPerson &a : e.attendees) + people << (a.name.isEmpty() ? a.address : a.name); + + m_editPath = e.filePath; + m_editBase = e.rawText; + m_pane->startEdit(initial, writableCollections(), m_editWholeSeries, m_editWholeSeries, + e.hasAlarm ? tr("Kept as set elsewhere") : tr("None"), + people.join(QStringLiteral(", "))); +} + +void CalendarWindow::startNew(const QDate &day) +{ + if (m_pane->isEditing() || defaultCollection().isEmpty()) + return; + EventEdit initial; + initial.start = QDateTime(day, QTime(9, 0)); + initial.end = initial.start.addSecs(3600); + initial.collectionDir = defaultCollection(); + m_editPath.clear(); + m_editBase.clear(); + m_editWholeSeries = true; + m_editRecurrenceId = {}; + m_pane->startEdit(initial, writableCollections(), true, true, tr("None"), QString()); +} + +bool CalendarWindow::save() +{ + const EventEdit edit = m_pane->edit(); + const CalCollection *target = collection(edit.collectionDir); + if (!target) + return false; + QList<Change> changes; + QString label; + + if (m_editPath.isEmpty()) { + const QByteArray text = CalendarStore::newEvent(edit, QTimeZone::systemTimeZoneId()); + bool ignored = false; + const QString uid = CalendarStore::parseEvent(text, {}, {}, &ignored).uid; + changes.append({ target->path + QLatin1Char('/') + uid + QStringLiteral(".ics"), + std::nullopt, text }); + label = tr("New event"); + } else { + const QByteArray text = CalendarStore::applyEdit( + m_editBase, edit, + m_editWholeSeries ? CalendarStore::Scope::All : CalendarStore::Scope::ThisOccurrence, + m_editRecurrenceId); + if (text.isEmpty()) + return false; + const QString newPath = target->path + QLatin1Char('/') + QFileInfo(m_editPath).fileName(); + if (newPath == m_editPath) { + changes.append({ m_editPath, m_editBase, text }); + } else { + // Moving to another calendar: the new file first, then the old + // one removed, so a failure between them duplicates rather than + // loses the event. + changes.append({ newPath, std::nullopt, text }); + changes.append({ m_editPath, m_editBase, std::nullopt }); + } + label = tr("Edit event"); + } + + if (!writeAndRecord(label, changes)) + return false; // the form stays open with the user's values + m_pane->stopEdit(); + bool ignored = false; + m_selectedUid = CalendarStore::parseEvent(*changes.first().after, {}, {}, &ignored).uid; + m_selectedStart = {}; + reload(); + return true; +} + +void CalendarWindow::cancelEdit() +{ + if (!m_pane->isEditing()) + return; + m_pane->stopEdit(); + rebuildItems(); +} + +void CalendarWindow::deleteSelected() +{ + if (m_pane->isEditing() || m_selected < 0) + return; + const Occurrence &o = m_items[m_selected].occurrence; + const CalEvent &e = m_data.events[o.eventIndex]; + const CalCollection *c = collection(e.collectionDir); + if (!c || !CalendarStore::isEditable(e, *c, m_ownAddresses)) + return; + + // No confirmation: the delete is undoable (spec decision 14). A repeating + // event asks its SCOPE, which is a choice, not a confirmation. + std::optional<QByteArray> after = std::nullopt; + if (e.repeat.freq != RepeatRule::Freq::None || e.repeat.custom) { + QMessageBox box(QMessageBox::Question, tr("Delete a repeating event"), + tr("Delete only this occurrence, or every occurrence?"), + QMessageBox::Cancel, this); + QPushButton *one = box.addButton(tr("This occurrence"), QMessageBox::AcceptRole); + QPushButton *all = box.addButton(tr("All occurrences"), QMessageBox::AcceptRole); + box.exec(); + if (box.clickedButton() == one) + after = CalendarStore::deleteOccurrence(e.rawText, o.recurrenceId); + else if (box.clickedButton() != all) + return; + } + if (writeAndRecord(tr("Delete event"), { { e.filePath, e.rawText, after } })) { + m_selectedUid.clear(); + reload(); + } +} + +bool CalendarWindow::applyChanges(const QList<Change> &changes, bool reverse) +{ + QList<Change> done; + for (int i = 0; i < changes.size(); ++i) { + const Change &c = changes[reverse ? changes.size() - 1 - i : i]; + const auto from = reverse ? c.after : c.before; + const auto to = reverse ? c.before : c.after; + QString error; + const CalendarWriter::Result r = CalendarWriter::replace(c.path, from, to, &error); + if (r != CalendarWriter::Result::Ok) { + // Roll back what this call already did, newest first. + // ponytail: best effort; a rollback that itself fails is reported + // by the file being wrong on the next reload, not handled here. + for (int j = done.size() - 1; j >= 0; --j) { + QString ignored; + CalendarWriter::replace(done[j].path, done[j].after, done[j].before, &ignored); + } + status(r == CalendarWriter::Result::Stale + ? tr("The event changed on disk, probably from a sync. Check it and try again.") + : tr("Could not write the calendar: %1").arg(error)); + reload(); + return false; + } + done.append({ c.path, from, to }); + m_written.insert(c.path, to); + } + m_sync->schedule(); + m_reloadTimer.start(); + return true; +} + +bool CalendarWindow::writeAndRecord(const QString &label, const QList<Change> &changes) +{ + if (!applyChanges(changes, false)) + return false; + m_undo.push(new FileCommand(label, changes, this)); + return true; +} + +void CalendarWindow::checkAfterSync() +{ + // Compared by MEANING: the server may normalise the text on the round + // trip, and a byte comparison would warn on every save. + QStringList lost; + for (auto it = m_checking.cbegin(); it != m_checking.cend(); ++it) { + const bool exists = QFile::exists(it.key()); + const bool kept = it.value() + ? exists && CalendarStore::sameMeaning(readFile(it.key()), *it.value()) + : !exists; + if (!kept) { + bool ignored = false; + const QString title = it.value() + ? CalendarStore::parseEvent(*it.value(), {}, {}, &ignored).summary + : QFileInfo(it.key()).completeBaseName(); + lost << title; + } + } + m_checking.clear(); + status(lost.isEmpty() + ? tr("Calendars synced.") + : tr("The server kept a different version of: %1").arg(lost.join(QStringLiteral(", ")))); +} + +void CalendarWindow::status(const QString &text) +{ + statusBar()->showMessage(text, 10000); +} + +void CalendarWindow::closeEvent(QCloseEvent *event) +{ + // A QMainWindow, not a QDialog, so every route out (the title bar, the + // File menu, close()) arrives here; AGENTS.md's QDialog trap does not + // apply, but the test still drives two routes. + if (m_pane->isDirty()) { + const auto answer = QMessageBox::question( + this, tr("Unsaved event"), tr("Save the changes to this event?"), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, QMessageBox::Save); + if (answer == QMessageBox::Cancel || (answer == QMessageBox::Save && !save())) { + event->ignore(); + return; + } + m_pane->stopEdit(); + } + QDir().mkpath(QFileInfo(m_uiStatePath).absolutePath()); + QSettings state(m_uiStatePath, QSettings::IniFormat); + state.beginGroup(QStringLiteral("calendar")); + state.setValue(QStringLiteral("geometry"), saveGeometry()); + state.setValue(QStringLiteral("agenda"), m_views->currentIndex() == 1); + state.setValue(QStringLiteral("collection"), m_collectionBox->currentData().toString()); + state.endGroup(); + QMainWindow::closeEvent(event); +} |
