MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - CalendarStore.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 96.8 % 622 602
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 45 45
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 55.3 % 1674 926

             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 : }
        

Generated by: LCOV version 2.0-1