PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); self::$pdo->exec('PRAGMA journal_mode = WAL'); self::$pdo->exec('PRAGMA foreign_keys = ON'); self::migrate(); } return self::$pdo; } private static function migrate(): void { self::$pdo->exec(' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) '); self::$pdo->exec(' CREATE TABLE IF NOT EXISTS buckets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) '); self::$pdo->exec(' CREATE TABLE IF NOT EXISTS cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT "", color TEXT NOT NULL DEFAULT "#ffffff", bucket_id INTEGER DEFAULT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bucket_id) REFERENCES buckets(id) ON DELETE SET NULL ) '); // Migrations for existing installs $cols = self::$pdo->query('PRAGMA table_info(cards)')->fetchAll(); $colNames = array_column($cols, 'name'); if (!in_array('bucket_id', $colNames)) { self::$pdo->exec('ALTER TABLE cards ADD COLUMN bucket_id INTEGER DEFAULT NULL REFERENCES buckets(id) ON DELETE SET NULL'); } if (!in_array('parent_id', $colNames)) { self::$pdo->exec('ALTER TABLE cards ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES cards(id) ON DELETE SET NULL'); } if (!in_array('tags', $colNames)) { self::$pdo->exec("ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT ''"); // Backfill tags from existing card bodies $all = self::$pdo->query('SELECT id, body FROM cards')->fetchAll(); foreach ($all as $row) { preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $row['body'], $matches); if (!empty($matches[1])) { $tags = implode(',', array_unique(array_map('strtolower', $matches[1]))); self::$pdo->prepare('UPDATE cards SET tags = :tags WHERE id = :id') ->execute(['tags' => $tags, 'id' => $row['id']]); } } } if (!in_array('due_date', $colNames)) { self::$pdo->exec('ALTER TABLE cards ADD COLUMN due_date TEXT DEFAULT NULL'); } // CalDAV columns if (!in_array('caldav_uid', $colNames)) { self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_uid TEXT DEFAULT NULL'); self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_etag TEXT DEFAULT NULL'); self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_href TEXT DEFAULT NULL'); self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_synced_at TEXT DEFAULT NULL'); self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL'); } if (!in_array('caldav_rrule', $colNames) && in_array('caldav_uid', $colNames)) { self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL'); } // CalDAV support tables self::$pdo->exec(' CREATE TABLE IF NOT EXISTS caldav_sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, direction TEXT NOT NULL, card_id INTEGER, caldav_uid TEXT, action TEXT NOT NULL, status TEXT NOT NULL, detail TEXT DEFAULT "", created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) '); self::$pdo->exec(' CREATE TABLE IF NOT EXISTS caldav_tombstones ( caldav_uid TEXT PRIMARY KEY, card_id INTEGER, deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) '); // Bucket color migration $bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll(); $bcolNames = array_column($bcols, 'name'); if (!in_array('color', $bcolNames)) { self::$pdo->exec("ALTER TABLE buckets ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'"); } } }