Branch data Line data Source code
1 : : #include "CalendarStore.h"
2 : : #include "DatabaseSecurity.h"
3 : : #include "DatabaseSync.h"
4 : :
5 : : #include "ui/ThemeManager.h"
6 : :
7 : : #include <QLoggingCategory>
8 : : #include <QSqlError>
9 : : #include <QSqlQuery>
10 : : #include <QTimeZone>
11 : : #include <QUuid>
12 : :
13 [ + + + - : 650 : Q_LOGGING_CATEGORY(lcCalStore, "mailjd.calendarstore")
+ - - - ]
14 : :
15 : 2358 : static bool execMigrationStatement(QSqlQuery &q, const QString &statement,
16 : : const QString &context) {
17 [ + + ]: 2358 : if (q.exec(statement))
18 : 2354 : return true;
19 [ + - + - : 8 : qCWarning(lcCalStore) << context << q.lastError().text() << statement;
+ - + - +
- + - + -
+ + ]
20 : 4 : return false;
21 : : }
22 : :
23 : 432 : static bool runCalendarMigration(QSqlDatabase &db, QSqlQuery &q,
24 : : int targetVersion,
25 : : const QString &context,
26 : : const QStringList &statements) {
27 [ - + ]: 432 : if (!db.transaction()) {
28 [ # # # # : 0 : qCWarning(lcCalStore) << context << "transaction:"
# # # # #
# ]
29 [ # # # # : 0 : << db.lastError().text();
# # ]
30 : 0 : return false;
31 : : }
32 : :
33 [ + + ]: 2358 : for (const QString &statement : statements) {
34 [ + - + + ]: 1930 : if (!execMigrationStatement(q, statement, context)) {
35 [ + - ]: 4 : db.rollback();
36 : 4 : return false;
37 : : }
38 : : }
39 : :
40 [ + - ]: 428 : if (!execMigrationStatement(
41 [ + - - + ]: 1284 : q, QStringLiteral("PRAGMA user_version = %1").arg(targetVersion),
42 : : context)) {
43 : 0 : db.rollback();
44 : 0 : return false;
45 : : }
46 : :
47 [ - + ]: 428 : if (!db.commit()) {
48 [ # # # # : 0 : qCWarning(lcCalStore) << context << "commit:" << db.lastError().text();
# # # # #
# # # # #
# # ]
49 : 0 : return false;
50 : : }
51 : 428 : return true;
52 : : }
53 : :
54 [ + - ]: 131 : CalendarStore::CalendarStore(QObject *parent) : QObject(parent) {}
55 : :
56 : 178 : CalendarStore::~CalendarStore() { close(); }
57 : :
58 : 126 : bool CalendarStore::open(const QString &dbPath) {
59 [ + - + + ]: 126 : if (!DatabaseSecurity::preparePath(dbPath)) {
60 [ + - + - : 6 : qCWarning(lcCalStore)
+ + ]
61 [ + - + - ]: 3 : << "Failed to create private calendar DB:" << dbPath;
62 : 3 : return false;
63 : : }
64 : : m_connectionName =
65 : 246 : QStringLiteral("calstore_") +
66 [ + - + - : 369 : QUuid::createUuid().toString(QUuid::WithoutBraces).left(8);
+ - + - ]
67 [ + - ]: 246 : m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"),
68 [ + - ]: 246 : m_connectionName);
69 [ + - ]: 123 : m_db.setDatabaseName(dbPath);
70 [ + - - + ]: 123 : if (!m_db.open()) {
71 [ # # # # : 0 : qCWarning(lcCalStore) << "Failed to open DB:" << m_db.lastError().text();
# # # # #
# # # #
# ]
72 : 0 : return false;
73 : : }
74 [ + - - + ]: 123 : if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
75 [ # # # # : 0 : qCWarning(lcCalStore)
# # ]
76 [ # # # # ]: 0 : << "Failed to restrict calendar DB permissions:" << dbPath;
77 [ # # ]: 0 : m_db.close();
78 : 0 : return false;
79 : : }
80 : : // WAL mode for concurrent reads
81 [ + - ]: 123 : QSqlQuery pragma(m_db);
82 [ + - ]: 123 : pragma.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
83 [ + - ]: 123 : pragma.exec(QStringLiteral("PRAGMA busy_timeout=5000"));
84 [ + - ]: 123 : pragma.exec(QStringLiteral("PRAGMA foreign_keys=ON"));
85 : :
86 [ + - ]: 123 : return createSchema();
87 : 123 : }
88 : :
89 : 169 : void CalendarStore::close() {
90 [ + + ]: 169 : if (m_db.isOpen())
91 : 123 : m_db.close();
92 : : // T-402/Bug 20: Release reference before removeDatabase
93 [ + - + - ]: 169 : m_db = QSqlDatabase();
94 [ + + ]: 169 : if (!m_connectionName.isEmpty()) {
95 : 123 : QSqlDatabase::removeDatabase(m_connectionName);
96 : 123 : m_connectionName.clear();
97 : : }
98 : 169 : }
99 : :
100 : 273 : bool CalendarStore::isOpen() const { return m_db.isOpen(); }
101 : :
102 : 123 : bool CalendarStore::createSchema() {
103 [ + - ]: 123 : QSqlQuery q(m_db);
104 : :
105 [ + - ]: 123 : bool ok = q.exec(QStringLiteral(
106 : : "CREATE TABLE IF NOT EXISTS calendars ("
107 : : " id INTEGER PRIMARY KEY,"
108 : : " path TEXT UNIQUE NOT NULL,"
109 : : " displayName TEXT,"
110 : : " color TEXT,"
111 : : " ctag TEXT,"
112 : : " accountId TEXT,"
113 : : " readOnly INTEGER DEFAULT 0"
114 : : ")"));
115 [ + + ]: 123 : if (!ok) {
116 [ + - + - : 2 : qCWarning(lcCalStore) << "calendars table:" << q.lastError().text();
+ - + - +
- + - +
+ ]
117 : 1 : return false;
118 : : }
119 : :
120 [ + - ]: 122 : ok = q.exec(QStringLiteral(
121 : : "CREATE TABLE IF NOT EXISTS events ("
122 : : " id INTEGER PRIMARY KEY,"
123 : : " calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,"
124 : : " uid TEXT NOT NULL,"
125 : : " summary TEXT,"
126 : : " description TEXT,"
127 : : " location TEXT,"
128 : : " dt_start INTEGER,"
129 : : " dt_end INTEGER,"
130 : : " all_day INTEGER DEFAULT 0,"
131 : : " rrule TEXT,"
132 : : " etag TEXT,"
133 : : " last_modified INTEGER,"
134 : : " UNIQUE(calendar_id, uid)"
135 : : ")"));
136 [ + + ]: 122 : if (!ok) {
137 [ + - + - : 2 : qCWarning(lcCalStore) << "events table:" << q.lastError().text();
+ - + - +
- + - +
+ ]
138 : 1 : return false;
139 : : }
140 : :
141 [ + - ]: 121 : ok = q.exec(QStringLiteral(
142 : : "CREATE TABLE IF NOT EXISTS tasks ("
143 : : " id INTEGER PRIMARY KEY,"
144 : : " calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,"
145 : : " uid TEXT NOT NULL,"
146 : : " summary TEXT,"
147 : : " description TEXT,"
148 : : " due INTEGER,"
149 : : " percent_complete INTEGER DEFAULT 0,"
150 : : " priority INTEGER DEFAULT 0,"
151 : : " status TEXT DEFAULT 'NEEDS-ACTION',"
152 : : " completed_at INTEGER,"
153 : : " etag TEXT,"
154 : : " last_modified INTEGER,"
155 : : " UNIQUE(calendar_id, uid)"
156 : : ")"));
157 [ + + ]: 121 : if (!ok) {
158 [ + - + - : 2 : qCWarning(lcCalStore) << "tasks table:" << q.lastError().text();
+ - + - +
- + - +
+ ]
159 : 1 : return false;
160 : : }
161 : :
162 : : // Index for date-range queries on events
163 [ + - ]: 120 : q.exec(QStringLiteral(
164 : : "CREATE INDEX IF NOT EXISTS idx_events_dates ON events(dt_start, dt_end)"));
165 : :
166 : : // --- Schema migration (Sprint 37 – T-451) ---
167 : : // Check user_version for migrations
168 : 120 : int version = 0;
169 [ + - + - : 240 : if (q.exec(QStringLiteral("PRAGMA user_version")) && q.next())
+ - + - +
- + - + -
- - - - ]
170 [ + - + - ]: 120 : version = q.value(0).toInt();
171 : :
172 [ + + ]: 120 : if (version < 1) {
173 [ + - + - : 216 : qCInfo(lcCalStore) << "Migrating schema v0 → v1 (adding dt_start, created, organizer)";
+ - + + ]
174 [ + + - - ]: 432 : if (!runCalendarMigration(
175 [ + - ]: 108 : m_db, q, 1, QStringLiteral("calendar v1 migration:"),
176 : : {
177 [ + + ]: 108 : QStringLiteral("ALTER TABLE tasks ADD COLUMN dt_start INTEGER"),
178 : 108 : QStringLiteral("ALTER TABLE tasks ADD COLUMN created INTEGER"),
179 : 108 : QStringLiteral("ALTER TABLE tasks ADD COLUMN organizer TEXT"),
180 : : })) {
181 : 1 : return false;
182 : : }
183 : 107 : version = 1;
184 : : }
185 : :
186 [ + + ]: 119 : if (version < 2) {
187 [ + - + - : 216 : qCInfo(lcCalStore)
+ + ]
188 [ + - ]: 108 : << "Migrating schema v1 → v2 (calendar identity includes accountId)";
189 [ + - ]: 108 : q.exec(QStringLiteral("PRAGMA foreign_keys=OFF"));
190 : : const QStringList statements = {
191 : 0 : QStringLiteral(
192 : : "CREATE TABLE calendars_new ("
193 : : " id INTEGER PRIMARY KEY,"
194 : : " path TEXT NOT NULL,"
195 : : " displayName TEXT,"
196 : : " color TEXT,"
197 : : " ctag TEXT,"
198 : : " accountId TEXT NOT NULL DEFAULT '',"
199 : : " readOnly INTEGER DEFAULT 0,"
200 : : " UNIQUE(accountId, path)"
201 : : ")"),
202 : 108 : QStringLiteral(
203 : : "INSERT INTO calendars_new "
204 : : "(id, path, displayName, color, ctag, accountId, readOnly) "
205 : : "SELECT id, path, displayName, color, ctag, "
206 : : "COALESCE(accountId, ''), readOnly FROM calendars"),
207 : 108 : QStringLiteral(
208 : : "CREATE TABLE events_new ("
209 : : " id INTEGER PRIMARY KEY,"
210 : : " calendar_id INTEGER REFERENCES calendars_new(id) ON DELETE CASCADE,"
211 : : " uid TEXT NOT NULL,"
212 : : " summary TEXT,"
213 : : " description TEXT,"
214 : : " location TEXT,"
215 : : " dt_start INTEGER,"
216 : : " dt_end INTEGER,"
217 : : " all_day INTEGER DEFAULT 0,"
218 : : " rrule TEXT,"
219 : : " etag TEXT,"
220 : : " last_modified INTEGER,"
221 : : " UNIQUE(calendar_id, uid)"
222 : : ")"),
223 : 108 : QStringLiteral(
224 : : "INSERT INTO events_new "
225 : : "(id, calendar_id, uid, summary, description, location, dt_start, "
226 : : "dt_end, all_day, rrule, etag, last_modified) "
227 : : "SELECT id, calendar_id, uid, summary, description, location, "
228 : : "dt_start, dt_end, all_day, rrule, etag, last_modified FROM events"),
229 : 108 : QStringLiteral(
230 : : "CREATE TABLE tasks_new ("
231 : : " id INTEGER PRIMARY KEY,"
232 : : " calendar_id INTEGER REFERENCES calendars_new(id) ON DELETE CASCADE,"
233 : : " uid TEXT NOT NULL,"
234 : : " summary TEXT,"
235 : : " description TEXT,"
236 : : " due INTEGER,"
237 : : " percent_complete INTEGER DEFAULT 0,"
238 : : " priority INTEGER DEFAULT 0,"
239 : : " status TEXT DEFAULT 'NEEDS-ACTION',"
240 : : " completed_at INTEGER,"
241 : : " etag TEXT,"
242 : : " last_modified INTEGER,"
243 : : " dt_start INTEGER,"
244 : : " created INTEGER,"
245 : : " organizer TEXT,"
246 : : " UNIQUE(calendar_id, uid)"
247 : : ")"),
248 : 108 : QStringLiteral(
249 : : "INSERT INTO tasks_new "
250 : : "(id, calendar_id, uid, summary, description, due, "
251 : : "percent_complete, priority, status, completed_at, etag, "
252 : : "last_modified, dt_start, created, organizer) "
253 : : "SELECT id, calendar_id, uid, summary, description, due, "
254 : : "percent_complete, priority, status, completed_at, etag, "
255 : : "last_modified, dt_start, created, organizer FROM tasks"),
256 : 108 : QStringLiteral("DROP TABLE tasks"),
257 : 108 : QStringLiteral("DROP TABLE events"),
258 : 108 : QStringLiteral("DROP TABLE calendars"),
259 : 108 : QStringLiteral("ALTER TABLE calendars_new RENAME TO calendars"),
260 : 108 : QStringLiteral("ALTER TABLE events_new RENAME TO events"),
261 : 108 : QStringLiteral("ALTER TABLE tasks_new RENAME TO tasks"),
262 [ + + - - ]: 1512 : };
263 : :
264 [ + - ]: 108 : if (!runCalendarMigration(m_db, q, 2,
265 [ + + ]: 216 : QStringLiteral("calendar v2 migration:"),
266 : : statements)) {
267 [ + - ]: 1 : q.exec(QStringLiteral("PRAGMA foreign_keys=ON"));
268 : 1 : return false;
269 : : }
270 [ + - ]: 107 : q.exec(QStringLiteral("PRAGMA foreign_keys=ON"));
271 : 107 : version = 2;
272 [ + + ]: 108 : }
273 : :
274 [ + + ]: 118 : if (version < 3) {
275 [ + - + - : 218 : qCInfo(lcCalStore)
+ + ]
276 [ + - ]: 109 : << "Migrating schema v2 → v3 (storing CalDAV resource hrefs)";
277 [ + + - - ]: 327 : if (!runCalendarMigration(
278 [ + - ]: 109 : m_db, q, 3, QStringLiteral("calendar v3 migration:"),
279 : : {
280 [ + + ]: 109 : QStringLiteral("ALTER TABLE events ADD COLUMN resource_href TEXT"),
281 : 109 : QStringLiteral("ALTER TABLE tasks ADD COLUMN resource_href TEXT"),
282 : : })) {
283 : 2 : return false;
284 : : }
285 : 107 : version = 3;
286 : : }
287 : :
288 [ + + ]: 116 : if (version < 4) {
289 [ + - + - : 214 : qCInfo(lcCalStore)
+ + ]
290 [ + - ]: 107 : << "Migrating schema v3 → v4 (EXDATE list, Sprint 79 T-79.C1)";
291 [ + + - - ]: 214 : if (!runCalendarMigration(
292 [ + - ]: 107 : m_db, q, 4, QStringLiteral("calendar v4 migration:"),
293 : : {
294 [ - + ]: 107 : QStringLiteral("ALTER TABLE events ADD COLUMN exdates TEXT"),
295 : : })) {
296 : 0 : return false;
297 : : }
298 : 107 : version = 4;
299 : : }
300 : :
301 [ + - ]: 116 : q.exec(QStringLiteral(
302 : : "CREATE INDEX IF NOT EXISTS idx_events_dates ON events(dt_start, dt_end)"));
303 : :
304 [ + - + - : 232 : qCDebug(lcCalStore) << "Schema created/verified (version:" << qMax(version, 4) << ")";
+ - + - +
- + + ]
305 : 116 : return true;
306 [ + - + - : 2500 : }
+ - + - -
- - - - -
- - - - -
- - - -
- ]
307 : :
308 : 153 : qint64 CalendarStore::calendarIdForPath(const QString &path) const {
309 [ + - ]: 153 : QSqlQuery q(m_db);
310 [ + - ]: 153 : q.prepare(QStringLiteral("SELECT id FROM calendars WHERE path = ? "
311 : : "ORDER BY id LIMIT 2"));
312 [ + - ]: 153 : q.addBindValue(path);
313 [ + - + + : 153 : if (!q.exec() || !q.next())
+ - + + +
+ ]
314 : 32 : return -1;
315 [ + - + - ]: 121 : const qint64 id = q.value(0).toLongLong();
316 [ + - + + ]: 121 : if (q.next()) {
317 [ + - + - : 10 : qCWarning(lcCalStore)
+ + ]
318 [ + - + - ]: 5 : << "calendarIdForPath: ambiguous path without accountId:" << path;
319 : 5 : return -1;
320 : : }
321 : 116 : return id;
322 : 153 : }
323 : :
324 : 384 : qint64 CalendarStore::calendarIdForPath(const QString &accountId,
325 : : const QString &path) const {
326 [ + + ]: 384 : if (accountId.isEmpty())
327 [ + - ]: 101 : return calendarIdForPath(path);
328 : :
329 [ + - ]: 283 : QSqlQuery q(m_db);
330 [ + - ]: 283 : q.prepare(QStringLiteral(
331 : : "SELECT id FROM calendars WHERE accountId = ? AND path = ?"));
332 [ + - ]: 283 : q.addBindValue(accountId);
333 [ + - ]: 283 : q.addBindValue(path);
334 [ + - + + : 283 : if (q.exec() && q.next())
+ - + + +
+ ]
335 [ + - + - ]: 276 : return q.value(0).toLongLong();
336 : 7 : return -1;
337 : 283 : }
338 : :
339 : : // ═══════════════════════════════════════════════════════
340 : : // Calendars
341 : : // ═══════════════════════════════════════════════════════
342 : :
343 : 131 : void CalendarStore::upsertCalendar(const CalendarInfo &cal,
344 : : const QString &accountId) {
345 [ + + ]: 131 : const QString owner = accountId.isEmpty() ? cal.accountId : accountId;
346 : : // Determine color: use server color, keep existing custom color, or generate
347 : : // a deterministic palette color for new calendars without server color
348 : 131 : QString color = cal.color;
349 [ + + ]: 131 : if (color.isEmpty()) {
350 [ + - ]: 47 : QString existing = calendarColor(owner, cal.path);
351 [ + + ]: 47 : if (existing.isEmpty()) {
352 : : // Shared palette (67.B3: ThemeManager owns all color decisions)
353 [ + - ]: 46 : const QStringList palette = ThemeManager::calendarPalette();
354 : 46 : color = palette.at(
355 [ + - + - ]: 46 : qHash(owner + QLatin1Char('\0') + cal.path) % palette.size());
356 : 46 : }
357 : 47 : }
358 : :
359 [ + - ]: 131 : QSqlQuery q(m_db);
360 [ + - ]: 131 : q.prepare(QStringLiteral(
361 : : "INSERT INTO calendars (path, displayName, color, ctag, accountId, readOnly) "
362 : : "VALUES (?, ?, ?, ?, ?, ?) "
363 : : "ON CONFLICT(accountId, path) DO UPDATE SET "
364 : : " displayName = excluded.displayName,"
365 : : " ctag = excluded.ctag,"
366 : : " readOnly = excluded.readOnly"));
367 [ + - ]: 131 : q.addBindValue(cal.path);
368 [ + - ]: 131 : q.addBindValue(cal.displayName);
369 [ + - ]: 131 : q.addBindValue(color);
370 [ + - ]: 131 : q.addBindValue(cal.ctag);
371 [ + - ]: 131 : q.addBindValue(owner);
372 [ + + + - ]: 131 : q.addBindValue(cal.readOnly ? 1 : 0);
373 [ + - + + ]: 131 : if (!q.exec())
374 [ + - + - : 2 : qCWarning(lcCalStore) << "upsertCalendar:" << q.lastError().text();
+ - + - +
- + - +
+ ]
375 : 131 : }
376 : :
377 : 117 : QList<CalendarInfo> CalendarStore::allCalendars() const {
378 : 117 : QList<CalendarInfo> result;
379 [ + - ]: 117 : QSqlQuery q(m_db);
380 [ + - ]: 117 : q.exec(QStringLiteral(
381 : : "SELECT path, accountId, displayName, color, ctag, readOnly "
382 : : "FROM calendars"));
383 [ + - + + ]: 310 : while (q.next()) {
384 : 193 : CalendarInfo cal;
385 [ + - + - ]: 193 : cal.path = q.value(0).toString();
386 [ + - + - ]: 193 : cal.accountId = q.value(1).toString();
387 [ + - + - ]: 193 : cal.displayName = q.value(2).toString();
388 [ + - + - ]: 193 : cal.color = q.value(3).toString();
389 [ + - + - ]: 193 : cal.ctag = q.value(4).toString();
390 [ + - + - ]: 193 : cal.readOnly = q.value(5).toBool();
391 [ + - ]: 193 : result.append(cal);
392 : 193 : }
393 : 117 : return result;
394 : 117 : }
395 : :
396 : 5 : void CalendarStore::removeCalendarsNotIn(const QString &accountId,
397 : : const QStringList &activePaths) {
398 [ + + ]: 5 : if (activePaths.isEmpty()) {
399 [ + - ]: 2 : QSqlQuery q(m_db);
400 [ + - ]: 2 : q.prepare(QStringLiteral("DELETE FROM calendars WHERE accountId = ?"));
401 [ + - ]: 2 : q.addBindValue(accountId);
402 [ + - ]: 2 : q.exec();
403 : 2 : return;
404 : 2 : }
405 : : // Build placeholder string
406 : 3 : QStringList placeholders;
407 [ + + ]: 7 : for (int i = 0; i < activePaths.size(); ++i)
408 [ + - ]: 4 : placeholders << QStringLiteral("?");
409 : :
410 [ + - ]: 3 : QSqlQuery q(m_db);
411 [ + - ]: 9 : q.prepare(QStringLiteral("DELETE FROM calendars WHERE accountId = ? "
412 : : "AND path NOT IN (%1)")
413 [ + - + - ]: 9 : .arg(placeholders.join(QStringLiteral(","))));
414 [ + - ]: 3 : q.addBindValue(accountId);
415 [ + + ]: 7 : for (const auto &p : activePaths)
416 [ + - ]: 4 : q.addBindValue(p);
417 [ + - ]: 3 : q.exec();
418 : 3 : }
419 : :
420 : 15 : QString CalendarStore::accountIdForCalendarPath(const QString &path) const {
421 [ + - ]: 15 : QSqlQuery q(m_db);
422 [ + - ]: 15 : q.prepare(QStringLiteral("SELECT accountId FROM calendars WHERE path = ? "
423 : : "ORDER BY id LIMIT 2"));
424 [ + - ]: 15 : q.addBindValue(path);
425 [ + - + + : 15 : if (!q.exec() || !q.next())
+ - + + +
+ ]
426 : 8 : return {};
427 [ + - + - ]: 7 : const QString accountId = q.value(0).toString();
428 [ + - + + ]: 7 : if (q.next()) {
429 [ + - + - : 4 : qCWarning(lcCalStore)
+ + ]
430 [ + - ]: 2 : << "accountIdForCalendarPath: ambiguous path without accountId:"
431 [ + - ]: 2 : << path;
432 : 2 : return {};
433 : : }
434 : 5 : return accountId;
435 : 15 : }
436 : :
437 : 21 : void CalendarStore::setCalendarColor(const QString &path,
438 : : const QString &color) {
439 [ + - ]: 21 : const qint64 calId = calendarIdForPath(path);
440 [ + + ]: 21 : if (calId < 0)
441 : 4 : return;
442 : :
443 [ + - ]: 17 : QSqlQuery q(m_db);
444 [ + - ]: 17 : q.prepare(QStringLiteral("UPDATE calendars SET color = ? WHERE id = ?"));
445 [ + - ]: 17 : q.addBindValue(color);
446 [ + - ]: 17 : q.addBindValue(calId);
447 [ + - + + ]: 17 : if (!q.exec())
448 [ + - + - : 2 : qCWarning(lcCalStore) << "setCalendarColor:" << q.lastError().text();
+ - + - +
- + - +
+ ]
449 : : else
450 [ + - + - : 32 : qCDebug(lcCalStore) << "setCalendarColor:" << path << "→" << color
+ - + - +
- + - +
+ ]
451 [ + - + - : 16 : << "rows:" << q.numRowsAffected();
+ - ]
452 : 17 : }
453 : :
454 : 4 : void CalendarStore::setCalendarColor(const QString &accountId,
455 : : const QString &path,
456 : : const QString &color) {
457 [ + - ]: 4 : QSqlQuery q(m_db);
458 [ + - ]: 4 : q.prepare(QStringLiteral(
459 : : "UPDATE calendars SET color = ? WHERE accountId = ? AND path = ?"));
460 [ + - ]: 4 : q.addBindValue(color);
461 [ + - ]: 4 : q.addBindValue(accountId);
462 [ + - ]: 4 : q.addBindValue(path);
463 [ + - + + ]: 4 : if (!q.exec())
464 [ + - + - : 2 : qCWarning(lcCalStore) << "setCalendarColor:" << q.lastError().text();
+ - + - +
- + - +
+ ]
465 : 4 : }
466 : :
467 : 31 : QString CalendarStore::calendarColor(const QString &path) const {
468 [ + - ]: 31 : const qint64 calId = calendarIdForPath(path);
469 [ + + ]: 31 : if (calId < 0) {
470 [ + - + - : 12 : qCDebug(lcCalStore) << "calendarColor:" << path << "= (not found)";
+ - + - +
- + + ]
471 : 6 : return {};
472 : : }
473 : :
474 [ + - ]: 25 : QSqlQuery q(m_db);
475 [ + - ]: 25 : q.prepare(QStringLiteral("SELECT color FROM calendars WHERE id = ?"));
476 [ + - ]: 25 : q.addBindValue(calId);
477 [ + - + + : 25 : if (q.exec() && q.next()) {
+ - + + +
+ ]
478 [ + - + - ]: 23 : QString c = q.value(0).toString();
479 [ + - + - : 46 : qCDebug(lcCalStore) << "calendarColor:" << path << "=" << c;
+ - + - +
- + - +
+ ]
480 : 23 : return c;
481 : 23 : }
482 [ + - + - : 4 : qCDebug(lcCalStore) << "calendarColor:" << path << "= (not found)";
+ - + - +
- + + ]
483 : 2 : return {};
484 : 25 : }
485 : :
486 : 55 : QString CalendarStore::calendarColor(const QString &accountId,
487 : : const QString &path) const {
488 [ + - ]: 55 : QSqlQuery q(m_db);
489 [ + - ]: 55 : q.prepare(QStringLiteral(
490 : : "SELECT color FROM calendars WHERE accountId = ? AND path = ?"));
491 [ + - ]: 55 : q.addBindValue(accountId);
492 [ + - ]: 55 : q.addBindValue(path);
493 [ + - + + : 55 : if (q.exec() && q.next())
+ - + + +
+ ]
494 [ + - + - ]: 7 : return q.value(0).toString();
495 : 48 : return {};
496 : 55 : }
497 : :
498 : 5 : void CalendarStore::beginTransaction() {
499 [ + + ]: 5 : if (m_inTransaction) return;
500 : 4 : m_inTransaction = m_db.transaction();
501 : : }
502 : :
503 : 6 : void CalendarStore::commitTransaction() {
504 [ + + ]: 6 : if (!m_inTransaction) return;
505 : 4 : m_db.commit();
506 : 4 : m_inTransaction = false;
507 : : }
508 : :
509 : : // ═══════════════════════════════════════════════════════
510 : : // Events
511 : : // ═══════════════════════════════════════════════════════
512 : :
513 : : // T-79.C1/H3: EXDATE list ↔ TEXT column (comma-joined ISO-8601 UTC).
514 : 94 : static QString serializeExdates(const QList<QDateTime> &exdates) {
515 : 94 : QStringList parts;
516 [ + + ]: 96 : for (const QDateTime &dt : exdates)
517 [ + - + - : 2 : parts << dt.toUTC().toString(Qt::ISODate);
+ - ]
518 [ + - ]: 188 : return parts.join(QLatin1Char(','));
519 : 94 : }
520 : :
521 : 229 : static QList<QDateTime> deserializeExdates(const QString &text) {
522 : 229 : QList<QDateTime> result;
523 [ + - ]: 229 : const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts);
524 [ + + ]: 231 : for (const QString &part : parts) {
525 [ + - ]: 2 : QDateTime dt = QDateTime::fromString(part, Qt::ISODate);
526 [ + - + - ]: 2 : if (dt.isValid())
527 [ + - ]: 2 : result.append(dt);
528 : 2 : }
529 : 229 : return result;
530 : 229 : }
531 : :
532 : 100 : void CalendarStore::upsertEvent(const CalendarEvent &event) {
533 [ + - ]: 100 : qint64 calId = calendarIdForPath(event.accountId, event.calendarPath);
534 [ + + ]: 100 : if (calId < 0) {
535 [ + - + - : 12 : qCWarning(lcCalStore) << "upsertEvent: calendar not found:"
+ - + + ]
536 [ + - + - ]: 6 : << event.accountId << event.calendarPath;
537 : 6 : return;
538 : : }
539 : :
540 [ + - ]: 94 : QSqlQuery q(m_db);
541 [ + - ]: 94 : q.prepare(QStringLiteral(
542 : : "INSERT INTO events (calendar_id, uid, summary, description, location, "
543 : : " dt_start, dt_end, all_day, rrule, etag, last_modified, resource_href, "
544 : : " exdates) "
545 : : "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
546 : : "ON CONFLICT(calendar_id, uid) DO UPDATE SET "
547 : : " summary = excluded.summary,"
548 : : " description = excluded.description,"
549 : : " location = excluded.location,"
550 : : " dt_start = excluded.dt_start,"
551 : : " dt_end = excluded.dt_end,"
552 : : " all_day = excluded.all_day,"
553 : : " rrule = excluded.rrule,"
554 : : " etag = excluded.etag,"
555 : : " last_modified = excluded.last_modified,"
556 : : " resource_href = excluded.resource_href,"
557 : : " exdates = excluded.exdates"));
558 [ + - ]: 94 : q.addBindValue(calId);
559 [ + - ]: 94 : q.addBindValue(event.uid);
560 [ + - ]: 94 : q.addBindValue(event.summary);
561 [ + - ]: 94 : q.addBindValue(event.description);
562 [ + - ]: 94 : q.addBindValue(event.location);
563 [ + - + + : 94 : q.addBindValue(event.dtStart.isValid() ? event.dtStart.toSecsSinceEpoch()
+ - + - ]
564 : : : QVariant());
565 [ + - + + : 94 : q.addBindValue(event.dtEnd.isValid() ? event.dtEnd.toSecsSinceEpoch()
+ - + - ]
566 : : : QVariant());
567 [ + + + - ]: 94 : q.addBindValue(event.allDay ? 1 : 0);
568 [ + - ]: 94 : q.addBindValue(event.rrule);
569 [ + - ]: 94 : q.addBindValue(event.etag);
570 [ + - + - ]: 188 : q.addBindValue(event.lastModified.isValid()
571 [ + + + - ]: 188 : ? event.lastModified.toSecsSinceEpoch()
572 : : : QVariant());
573 [ + - ]: 94 : q.addBindValue(event.resourceHref);
574 [ + - + - ]: 94 : q.addBindValue(serializeExdates(event.exdates));
575 [ + - + + ]: 94 : if (!q.exec())
576 [ + - + - : 2 : qCWarning(lcCalStore) << "upsertEvent:" << q.lastError().text();
+ - + - +
- + - +
+ ]
577 : 94 : }
578 : :
579 : : QList<CalendarEvent>
580 : 99 : CalendarStore::eventsForDateRange(const QDateTime &from,
581 : : const QDateTime &to) const {
582 : 99 : QList<CalendarEvent> result;
583 [ + - ]: 99 : QSqlQuery q(m_db);
584 [ + - ]: 99 : q.prepare(QStringLiteral(
585 : : "SELECT e.id, e.uid, c.path, c.accountId, e.summary, e.description, e.location, "
586 : : " e.dt_start, e.dt_end, e.all_day, e.rrule, e.etag, e.last_modified, "
587 : : " c.color, e.resource_href, e.exdates "
588 : : "FROM events e JOIN calendars c ON e.calendar_id = c.id "
589 : : "WHERE (e.dt_start <= ? AND (e.dt_end >= ? OR e.dt_end IS NULL)) "
590 : : " OR (e.rrule != '' AND e.dt_start <= ?) "
591 : : "ORDER BY e.dt_start ASC"));
592 [ + - + - ]: 99 : q.addBindValue(to.toSecsSinceEpoch());
593 [ + - + - ]: 99 : q.addBindValue(from.toSecsSinceEpoch());
594 [ + - + - ]: 99 : q.addBindValue(to.toSecsSinceEpoch());
595 [ + - + + ]: 99 : if (!q.exec())
596 : 2 : return result;
597 : :
598 [ + - + + ]: 301 : while (q.next()) {
599 : 204 : CalendarEvent ev;
600 [ + - + - ]: 204 : ev.id = q.value(0).toLongLong();
601 [ + - + - ]: 204 : ev.uid = q.value(1).toString();
602 [ + - + - ]: 204 : ev.calendarPath = q.value(2).toString();
603 [ + - + - ]: 204 : ev.accountId = q.value(3).toString();
604 [ + - + - ]: 204 : ev.summary = q.value(4).toString();
605 [ + - + - ]: 204 : ev.description = q.value(5).toString();
606 [ + - + - ]: 204 : ev.location = q.value(6).toString();
607 [ + - + - : 204 : if (!q.value(7).isNull())
+ - ]
608 [ + - + - : 204 : ev.dtStart = QDateTime::fromSecsSinceEpoch(q.value(7).toLongLong(), QTimeZone::utc());
+ - + - ]
609 [ + - + - : 204 : if (!q.value(8).isNull())
+ + ]
610 [ + - + - : 202 : ev.dtEnd = QDateTime::fromSecsSinceEpoch(q.value(8).toLongLong(), QTimeZone::utc());
+ - + - ]
611 [ + - + - ]: 204 : ev.allDay = q.value(9).toBool();
612 [ + - + - ]: 204 : ev.rrule = q.value(10).toString();
613 [ + - + - ]: 204 : ev.etag = q.value(11).toString();
614 [ + - + - : 204 : if (!q.value(12).isNull())
+ + ]
615 : : ev.lastModified =
616 [ + - + - : 3 : QDateTime::fromSecsSinceEpoch(q.value(12).toLongLong(), QTimeZone::utc());
+ - + - ]
617 [ + - + - ]: 204 : ev.color = q.value(13).toString();
618 [ + - + - ]: 204 : ev.resourceHref = q.value(14).toString();
619 [ + - + - : 204 : ev.exdates = deserializeExdates(q.value(15).toString());
+ - ]
620 [ + - ]: 204 : result.append(ev);
621 : 204 : }
622 : 97 : return result;
623 : 99 : }
624 : :
625 : : QList<CalendarEvent>
626 : 15 : CalendarStore::eventsForCalendar(const QString &calendarPath) const {
627 [ + - ]: 15 : return eventsForCalendar(QString(), calendarPath);
628 : : }
629 : :
630 : : QList<CalendarEvent>
631 : 38 : CalendarStore::eventsForCalendar(const QString &accountId,
632 : : const QString &calendarPath) const {
633 : 38 : QList<CalendarEvent> result;
634 [ + - ]: 38 : qint64 calId = calendarIdForPath(accountId, calendarPath);
635 [ + + ]: 38 : if (calId < 0)
636 : 9 : return result;
637 : :
638 [ + - ]: 29 : QSqlQuery q(m_db);
639 [ + - ]: 29 : q.prepare(QStringLiteral(
640 : : "SELECT id, uid, summary, description, location, "
641 : : " dt_start, dt_end, all_day, rrule, etag, last_modified, resource_href, "
642 : : " exdates "
643 : : "FROM events WHERE calendar_id = ? ORDER BY dt_start ASC"));
644 [ + - ]: 29 : q.addBindValue(calId);
645 [ + - + + ]: 29 : if (!q.exec())
646 : 1 : return result;
647 : :
648 [ + - + + ]: 53 : while (q.next()) {
649 : 25 : CalendarEvent ev;
650 [ + - + - ]: 25 : ev.id = q.value(0).toLongLong();
651 [ + - + - ]: 25 : ev.uid = q.value(1).toString();
652 : 25 : ev.calendarPath = calendarPath;
653 : 25 : ev.accountId = accountId;
654 [ + - + - ]: 25 : ev.summary = q.value(2).toString();
655 [ + - + - ]: 25 : ev.description = q.value(3).toString();
656 [ + - + - ]: 25 : ev.location = q.value(4).toString();
657 [ + - + - : 25 : if (!q.value(5).isNull())
+ + ]
658 [ + - + - : 22 : ev.dtStart = QDateTime::fromSecsSinceEpoch(q.value(5).toLongLong(), QTimeZone::utc());
+ - + - ]
659 [ + - + - : 25 : if (!q.value(6).isNull())
+ + ]
660 [ + - + - : 13 : ev.dtEnd = QDateTime::fromSecsSinceEpoch(q.value(6).toLongLong(), QTimeZone::utc());
+ - + - ]
661 [ + - + - ]: 25 : ev.allDay = q.value(7).toBool();
662 [ + - + - ]: 25 : ev.rrule = q.value(8).toString();
663 [ + - + - ]: 25 : ev.etag = q.value(9).toString();
664 [ + - + - : 25 : if (!q.value(10).isNull())
+ + ]
665 : : ev.lastModified =
666 [ + - + - : 1 : QDateTime::fromSecsSinceEpoch(q.value(10).toLongLong(), QTimeZone::utc());
+ - + - ]
667 [ + - + - ]: 25 : ev.resourceHref = q.value(11).toString();
668 [ + - + - : 25 : ev.exdates = deserializeExdates(q.value(12).toString());
+ - ]
669 [ + - ]: 25 : result.append(ev);
670 : 25 : }
671 : 28 : return result;
672 : 29 : }
673 : :
674 : 1 : void CalendarStore::removeStaleEvents(const QString &calendarPath,
675 : : const QStringList &activeUids,
676 : : bool allowEmpty) {
677 [ + - ]: 1 : removeStaleEvents(QString(), calendarPath, activeUids, allowEmpty);
678 : 1 : }
679 : :
680 : 8 : void CalendarStore::removeStaleEvents(const QString &accountId,
681 : : const QString &calendarPath,
682 : : const QStringList &activeUids,
683 : : bool allowEmpty) {
684 [ + - ]: 8 : qint64 calId = calendarIdForPath(accountId, calendarPath);
685 [ + + ]: 8 : if (calId < 0)
686 : 3 : return;
687 : :
688 [ + + ]: 7 : if (activeUids.isEmpty()) {
689 : : // SEC-2026-07-21-17: Guard against empty list — a valid-but-empty sync
690 : : // response (e.g. from a transparent proxy or broken server upgrade) must
691 : : // not silently delete the entire local cache including locally created,
692 : : // not-yet-uploaded entries.
693 [ + + ]: 2 : if (!allowEmpty) {
694 [ + - + - : 2 : qCWarning(lcCalStore)
+ + ]
695 [ + - ]: 1 : << "removeStaleEvents: activeUids is empty — skipping delete"
696 [ + - ]: 1 : << "(use allowEmpty=true to force)";
697 : 1 : return;
698 : : }
699 [ + - ]: 1 : QSqlQuery q(m_db);
700 [ + - ]: 1 : q.prepare(QStringLiteral("DELETE FROM events WHERE calendar_id = ?"));
701 [ + - ]: 1 : q.addBindValue(calId);
702 [ + - ]: 1 : q.exec();
703 : 1 : return;
704 : 1 : }
705 : :
706 : 5 : QString error;
707 [ + + + + : 10 : if (!DatabaseSync::removeRowsMissingFromUidSet(
- - ]
708 [ + - ]: 5 : m_db,
709 : 10 : QStringLiteral(
710 : : "DELETE FROM events WHERE calendar_id = ? "
711 : : "AND NOT EXISTS (SELECT 1 FROM mailjd_active_sync_uids active "
712 : : "WHERE active.uid = events.uid)"),
713 : : {calId}, activeUids, &error)) {
714 [ + - + - : 2 : qCWarning(lcCalStore) << "removeStaleEvents:" << error;
+ - + - +
+ ]
715 : : }
716 [ + - - - : 10 : }
- - ]
717 : :
718 : 5 : void CalendarStore::deleteEvent(const QString &uid,
719 : : const QString &calendarPath) {
720 [ + - ]: 5 : deleteEvent(uid, QString(), calendarPath);
721 : 5 : }
722 : :
723 : 11 : void CalendarStore::deleteEvent(const QString &uid,
724 : : const QString &accountId,
725 : : const QString &calendarPath) {
726 [ + - ]: 11 : qint64 calId = calendarIdForPath(accountId, calendarPath);
727 [ + + ]: 11 : if (calId < 0) {
728 [ + - + - : 8 : qCWarning(lcCalStore) << "deleteEvent: calendar not found:" << calendarPath;
+ - + - +
+ ]
729 : 4 : return;
730 : : }
731 : :
732 [ + - ]: 7 : QSqlQuery q(m_db);
733 [ + - ]: 7 : q.prepare(
734 : 14 : QStringLiteral("DELETE FROM events WHERE uid = ? AND calendar_id = ?"));
735 [ + - ]: 7 : q.addBindValue(uid);
736 [ + - ]: 7 : q.addBindValue(calId);
737 [ + - + + ]: 7 : if (!q.exec())
738 [ + - + - : 2 : qCWarning(lcCalStore) << "deleteEvent:" << q.lastError().text();
+ - + - +
- + - +
+ ]
739 : : else
740 [ + - + - : 12 : qCDebug(lcCalStore) << "deleteEvent:" << uid << "rows:"
+ - + - +
- + + ]
741 [ + - + - ]: 6 : << q.numRowsAffected();
742 : 7 : }
743 : :
744 : : // ═══════════════════════════════════════════════════════
745 : : // Tasks
746 : : // ═══════════════════════════════════════════════════════
747 : :
748 : 182 : void CalendarStore::upsertTask(const CalendarTask &task) {
749 [ + - ]: 182 : qint64 calId = calendarIdForPath(task.accountId, task.calendarPath);
750 [ + + ]: 182 : if (calId < 0) {
751 [ + - + - : 8 : qCWarning(lcCalStore) << "upsertTask: calendar not found:"
+ - + + ]
752 [ + - + - ]: 4 : << task.accountId << task.calendarPath;
753 : 4 : return;
754 : : }
755 : :
756 [ + - ]: 178 : QSqlQuery q(m_db);
757 [ + - ]: 178 : q.prepare(QStringLiteral(
758 : : "INSERT INTO tasks (calendar_id, uid, summary, description, due, "
759 : : " percent_complete, priority, status, completed_at, etag, last_modified, "
760 : : " dt_start, created, organizer, resource_href) "
761 : : "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
762 : : "ON CONFLICT(calendar_id, uid) DO UPDATE SET "
763 : : " summary = excluded.summary,"
764 : : " description = excluded.description,"
765 : : " due = excluded.due,"
766 : : " percent_complete = excluded.percent_complete,"
767 : : " priority = excluded.priority,"
768 : : " status = excluded.status,"
769 : : " completed_at = excluded.completed_at,"
770 : : " etag = excluded.etag,"
771 : : " last_modified = excluded.last_modified,"
772 : : " dt_start = excluded.dt_start,"
773 : : " created = excluded.created,"
774 : : " organizer = excluded.organizer,"
775 : : " resource_href = excluded.resource_href"));
776 [ + - ]: 178 : q.addBindValue(calId);
777 [ + - ]: 178 : q.addBindValue(task.uid);
778 [ + - ]: 178 : q.addBindValue(task.summary);
779 [ + - ]: 178 : q.addBindValue(task.description);
780 [ + - + + : 178 : q.addBindValue(task.due.isValid() ? task.due.toSecsSinceEpoch()
+ - + - ]
781 : : : QVariant());
782 [ + - ]: 178 : q.addBindValue(task.percentComplete);
783 [ + - ]: 178 : q.addBindValue(task.priority);
784 [ + - ]: 178 : q.addBindValue(task.status);
785 [ + - + - ]: 356 : q.addBindValue(task.completedAt.isValid()
786 [ + + + - ]: 356 : ? task.completedAt.toSecsSinceEpoch()
787 : : : QVariant());
788 [ + - ]: 178 : q.addBindValue(task.etag);
789 [ + - + - ]: 356 : q.addBindValue(task.lastModified.isValid()
790 [ + + + - ]: 356 : ? task.lastModified.toSecsSinceEpoch()
791 : : : QVariant());
792 [ + - + + : 178 : q.addBindValue(task.dtStart.isValid() ? task.dtStart.toSecsSinceEpoch()
+ - + - ]
793 : : : QVariant());
794 [ + - + + : 178 : q.addBindValue(task.created.isValid() ? task.created.toSecsSinceEpoch()
+ - + - ]
795 : : : QVariant());
796 [ + + + - ]: 178 : q.addBindValue(task.organizer.isEmpty() ? QVariant() : task.organizer);
797 [ + - ]: 178 : q.addBindValue(task.resourceHref);
798 [ + - + + ]: 178 : if (!q.exec())
799 [ + - + - : 2 : qCWarning(lcCalStore) << "upsertTask:" << q.lastError().text();
+ - + - +
- + - +
+ ]
800 : 178 : }
801 : :
802 : : // Helper: populate CalendarTask from query result row.
803 : : // Columns: 0=id, 1=uid, 2=c.path, 3=c.accountId, 4=summary, ...
804 : 458 : static CalendarTask taskFromQuery(QSqlQuery &q) {
805 : 458 : CalendarTask task;
806 [ + - + - ]: 458 : task.id = q.value(0).toLongLong();
807 [ + - + - ]: 458 : task.uid = q.value(1).toString();
808 [ + - + - ]: 458 : task.calendarPath = q.value(2).toString();
809 [ + - + - ]: 458 : task.accountId = q.value(3).toString();
810 [ + - + - ]: 458 : task.summary = q.value(4).toString();
811 [ + - + - ]: 458 : task.description = q.value(5).toString();
812 [ + - + - : 458 : if (!q.value(6).isNull())
+ + ]
813 [ + - + - : 411 : task.due = QDateTime::fromSecsSinceEpoch(q.value(6).toLongLong(), QTimeZone::utc());
+ - + - ]
814 [ + - + - ]: 458 : task.percentComplete = q.value(7).toInt();
815 [ + - + - ]: 458 : task.priority = q.value(8).toInt();
816 [ + - + - ]: 458 : task.status = q.value(9).toString();
817 [ + - + - : 458 : if (!q.value(10).isNull())
+ + ]
818 : : task.completedAt =
819 [ + - + - : 5 : QDateTime::fromSecsSinceEpoch(q.value(10).toLongLong(), QTimeZone::utc());
+ - + - ]
820 [ + - + - ]: 458 : task.etag = q.value(11).toString();
821 [ + - + - : 458 : if (!q.value(12).isNull())
+ + ]
822 : : task.lastModified =
823 [ + - + - : 34 : QDateTime::fromSecsSinceEpoch(q.value(12).toLongLong(), QTimeZone::utc());
+ - + - ]
824 [ + - + - : 458 : if (!q.value(13).isNull())
+ + ]
825 : : task.dtStart =
826 [ + - + - : 7 : QDateTime::fromSecsSinceEpoch(q.value(13).toLongLong(), QTimeZone::utc());
+ - + - ]
827 [ + - + - : 458 : if (!q.value(14).isNull())
+ + ]
828 : : task.created =
829 [ + - + - : 11 : QDateTime::fromSecsSinceEpoch(q.value(14).toLongLong(), QTimeZone::utc());
+ - + - ]
830 [ + - + - ]: 458 : task.organizer = q.value(15).toString();
831 [ + - + - ]: 458 : task.color = q.value(16).toString();
832 [ + - + - ]: 458 : task.calendarDisplayName = q.value(17).toString();
833 [ + - + - ]: 458 : task.resourceHref = q.value(18).toString();
834 : 458 : return task;
835 : 0 : }
836 : :
837 : : static const char *kTaskSelect =
838 : : "SELECT t.id, t.uid, c.path, c.accountId, t.summary, t.description, t.due, "
839 : : " t.percent_complete, t.priority, t.status, t.completed_at, "
840 : : " t.etag, t.last_modified, t.dt_start, t.created, "
841 : : " t.organizer, c.color, c.displayName, t.resource_href "
842 : : "FROM tasks t JOIN calendars c ON t.calendar_id = c.id ";
843 : :
844 : 95 : QList<CalendarTask> CalendarStore::allTasks() const {
845 : 95 : QList<CalendarTask> result;
846 [ + - ]: 95 : QSqlQuery q(m_db);
847 [ + - + - ]: 190 : q.exec(QString::fromLatin1(kTaskSelect) +
848 [ + - ]: 285 : QStringLiteral("ORDER BY t.due ASC NULLS LAST, t.priority DESC"));
849 [ + - + + ]: 498 : while (q.next())
850 [ + - + - ]: 403 : result.append(taskFromQuery(q));
851 : 95 : return result;
852 : 95 : }
853 : :
854 : 10 : QList<CalendarTask> CalendarStore::openTasks() const {
855 : 10 : QList<CalendarTask> result;
856 [ + - ]: 10 : QSqlQuery q(m_db);
857 [ + - + - ]: 20 : q.exec(QString::fromLatin1(kTaskSelect) +
858 [ + - ]: 30 : QStringLiteral(
859 : : "WHERE t.status != 'COMPLETED' AND t.status != 'CANCELLED' "
860 : : "ORDER BY t.due ASC NULLS LAST, t.priority DESC"));
861 [ + - + + ]: 21 : while (q.next())
862 [ + - + - ]: 11 : result.append(taskFromQuery(q));
863 : 10 : return result;
864 : 10 : }
865 : :
866 : : QList<CalendarTask>
867 : 11 : CalendarStore::tasksForCalendar(const QString &calendarPath) const {
868 [ + - ]: 11 : return tasksForCalendar(QString(), calendarPath);
869 : : }
870 : :
871 : : QList<CalendarTask>
872 : 26 : CalendarStore::tasksForCalendar(const QString &accountId,
873 : : const QString &calendarPath) const {
874 : 26 : QList<CalendarTask> result;
875 [ + - ]: 26 : qint64 calId = calendarIdForPath(accountId, calendarPath);
876 [ + + ]: 26 : if (calId < 0)
877 : 5 : return result;
878 : :
879 [ + - ]: 21 : QSqlQuery q(m_db);
880 [ + - + - ]: 42 : q.prepare(QString::fromLatin1(kTaskSelect) +
881 [ + - ]: 63 : QStringLiteral(
882 : : "WHERE t.calendar_id = ? "
883 : : "ORDER BY t.due ASC NULLS LAST, t.priority DESC"));
884 [ + - ]: 21 : q.addBindValue(calId);
885 [ + - + + ]: 21 : if (!q.exec())
886 : 1 : return result;
887 : :
888 [ + - + + ]: 42 : while (q.next())
889 [ + - + - ]: 22 : result.append(taskFromQuery(q));
890 : 20 : return result;
891 : 21 : }
892 : :
893 : 2 : void CalendarStore::removeStaleTasks(const QString &calendarPath,
894 : : const QStringList &activeUids,
895 : : bool allowEmpty) {
896 [ + - ]: 2 : removeStaleTasks(QString(), calendarPath, activeUids, allowEmpty);
897 : 2 : }
898 : :
899 : 10 : void CalendarStore::removeStaleTasks(const QString &accountId,
900 : : const QString &calendarPath,
901 : : const QStringList &activeUids,
902 : : bool allowEmpty) {
903 [ + - ]: 10 : qint64 calId = calendarIdForPath(accountId, calendarPath);
904 [ + + ]: 10 : if (calId < 0)
905 : 3 : return;
906 : :
907 [ + + ]: 9 : if (activeUids.isEmpty()) {
908 : : // SEC-2026-07-21-17: same guard as removeStaleEvents.
909 [ - + ]: 2 : if (!allowEmpty) {
910 [ # # # # : 0 : qCWarning(lcCalStore)
# # ]
911 [ # # ]: 0 : << "removeStaleTasks: activeUids is empty — skipping delete"
912 [ # # ]: 0 : << "(use allowEmpty=true to force)";
913 : 0 : return;
914 : : }
915 [ + - ]: 2 : QSqlQuery q(m_db);
916 [ + - ]: 2 : q.prepare(QStringLiteral("DELETE FROM tasks WHERE calendar_id = ?"));
917 [ + - ]: 2 : q.addBindValue(calId);
918 [ + - ]: 2 : q.exec();
919 : 2 : return;
920 : 2 : }
921 : :
922 : 7 : QString error;
923 [ + + + + : 14 : if (!DatabaseSync::removeRowsMissingFromUidSet(
- - ]
924 [ + - ]: 7 : m_db,
925 : 14 : QStringLiteral(
926 : : "DELETE FROM tasks WHERE calendar_id = ? "
927 : : "AND NOT EXISTS (SELECT 1 FROM mailjd_active_sync_uids active "
928 : : "WHERE active.uid = tasks.uid)"),
929 : : {calId}, activeUids, &error)) {
930 [ + - + - : 2 : qCWarning(lcCalStore) << "removeStaleTasks:" << error;
+ - + - +
+ ]
931 : : }
932 [ + - - - : 14 : }
- - ]
933 : :
934 : 5 : void CalendarStore::deleteTask(const QString &uid,
935 : : const QString &calendarPath) {
936 [ + - ]: 5 : deleteTask(uid, QString(), calendarPath);
937 : 5 : }
938 : :
939 : 9 : void CalendarStore::deleteTask(const QString &uid,
940 : : const QString &accountId,
941 : : const QString &calendarPath) {
942 [ + - ]: 9 : qint64 calId = calendarIdForPath(accountId, calendarPath);
943 [ + + ]: 9 : if (calId < 0) {
944 [ + - + - : 8 : qCWarning(lcCalStore) << "deleteTask: calendar not found:" << calendarPath;
+ - + - +
+ ]
945 : 4 : return;
946 : : }
947 [ + - ]: 5 : QSqlQuery q(m_db);
948 [ + - ]: 5 : q.prepare(
949 : 10 : QStringLiteral("DELETE FROM tasks WHERE uid = ? AND calendar_id = ?"));
950 [ + - ]: 5 : q.addBindValue(uid);
951 [ + - ]: 5 : q.addBindValue(calId);
952 [ + - + + ]: 5 : if (!q.exec())
953 [ + - + - : 2 : qCWarning(lcCalStore) << "deleteTask:" << q.lastError().text();
+ - + - +
- + - +
+ ]
954 : : else
955 [ + - + - : 8 : qCDebug(lcCalStore) << "deleteTask:" << uid << "rows:"
+ - + - +
- + + ]
956 [ + - + - ]: 4 : << q.numRowsAffected();
957 : 5 : }
958 : :
959 : : // ═══════════════════════════════════════════════════════
960 : : // Task Queries (Sprint 37 – T-451)
961 : : // ═══════════════════════════════════════════════════════
962 : :
963 : 87 : QMap<QString, int> CalendarStore::openTaskCountByCalendar() const {
964 : 87 : QMap<QString, int> result;
965 [ + - ]: 87 : QSqlQuery q(m_db);
966 [ + - ]: 87 : q.exec(QStringLiteral(
967 : : "SELECT c.path, COUNT(*) FROM tasks t "
968 : : "JOIN calendars c ON t.calendar_id = c.id "
969 : : "WHERE t.status != 'COMPLETED' AND t.status != 'CANCELLED' "
970 : : "GROUP BY c.path"));
971 [ + - + + ]: 247 : while (q.next())
972 [ + - + - : 160 : result.insert(q.value(0).toString(), q.value(1).toInt());
+ - + - +
- ]
973 : 87 : return result;
974 : 87 : }
975 : :
976 : : QList<CalendarTask>
977 : 14 : CalendarStore::searchTasks(const QString &query,
978 : : bool includeCompleted) const {
979 : 14 : QList<CalendarTask> result;
980 [ + + ]: 14 : if (query.isEmpty())
981 : 2 : return result;
982 : :
983 : : // SEC-2026-07-21-22: Escape LIKE wildcards in user input.
984 : 12 : QString escapedQuery = query;
985 [ + - ]: 12 : escapedQuery.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
986 [ + - ]: 12 : escapedQuery.replace(QLatin1Char('%'), QStringLiteral("\\%"));
987 [ + - ]: 12 : escapedQuery.replace(QLatin1Char('_'), QStringLiteral("\\_"));
988 : : QString likePattern =
989 [ + - + - ]: 12 : QLatin1Char('%') + escapedQuery + QLatin1Char('%');
990 : :
991 [ + - ]: 12 : QSqlQuery q(m_db);
992 [ + - ]: 24 : QString sql = QString::fromLatin1(kTaskSelect) +
993 [ + - ]: 36 : QStringLiteral(
994 : : "WHERE (t.summary LIKE ? ESCAPE '\\' OR "
995 : : "t.description LIKE ? ESCAPE '\\') ");
996 [ + + ]: 12 : if (!includeCompleted)
997 [ + - ]: 9 : sql += QStringLiteral(
998 : : "AND t.status != 'COMPLETED' AND t.status != 'CANCELLED' ");
999 [ + - ]: 12 : sql += QStringLiteral("ORDER BY t.due ASC NULLS LAST, t.priority DESC");
1000 : :
1001 [ + - ]: 12 : q.prepare(sql);
1002 [ + - ]: 12 : q.addBindValue(likePattern);
1003 [ + - ]: 12 : q.addBindValue(likePattern);
1004 [ + - + + ]: 12 : if (!q.exec())
1005 : 1 : return result;
1006 : :
1007 [ + - + + ]: 21 : while (q.next())
1008 [ + - + - ]: 10 : result.append(taskFromQuery(q));
1009 : 11 : return result;
1010 : 12 : }
1011 : :
1012 : 6 : QList<CalendarTask> CalendarStore::urgentTasks() const {
1013 : 6 : QList<CalendarTask> result;
1014 [ + - ]: 6 : QSqlQuery q(m_db);
1015 : :
1016 : : // Tasks due within 7 days (including overdue)
1017 [ + - + - ]: 6 : qint64 now = QDateTime::currentDateTimeUtc().toSecsSinceEpoch();
1018 : 6 : qint64 sevenDays = now + 7 * 24 * 3600;
1019 : :
1020 [ + - + - ]: 12 : q.prepare(QString::fromLatin1(kTaskSelect) +
1021 [ + - ]: 18 : QStringLiteral(
1022 : : "WHERE t.due IS NOT NULL AND t.due <= ? "
1023 : : "AND t.status != 'COMPLETED' AND t.status != 'CANCELLED' "
1024 : : "ORDER BY t.due ASC"));
1025 [ + - ]: 6 : q.addBindValue(sevenDays);
1026 [ + - + + ]: 6 : if (!q.exec())
1027 : 2 : return result;
1028 : :
1029 [ + - + + ]: 11 : while (q.next())
1030 [ + - + - ]: 7 : result.append(taskFromQuery(q));
1031 : 4 : return result;
1032 : 6 : }
1033 : :
1034 : 8 : QList<CalendarTask> CalendarStore::completedTasks() const {
1035 : 8 : QList<CalendarTask> result;
1036 [ + - ]: 8 : QSqlQuery q(m_db);
1037 [ + - + - ]: 16 : q.exec(QString::fromLatin1(kTaskSelect) +
1038 [ + - ]: 24 : QStringLiteral(
1039 : : "WHERE t.status = 'COMPLETED' "
1040 : : "ORDER BY t.completed_at DESC NULLS LAST"));
1041 [ + - + + ]: 13 : while (q.next())
1042 [ + - + - ]: 5 : result.append(taskFromQuery(q));
1043 : 8 : return result;
1044 : 8 : }
|