Files
hyprcrd/src/Database.php
T
thandClaude Opus 4.6 08f73dfd19 super-buckets, dnd insert cursor for cards + buckets, drag-state survives polls
Super-buckets: a bucket can act as a dropdown of nested children, 1-level
deep, created by dragging one bucket onto the center of another. The
parent shows up as a single tile in the bar with a stack icon and the
active member's name; clicking opens a popover that lists parent + all
children. Selecting an entry sets state.activeBucket. Implementation:

* src/Database.php — ALTER TABLE buckets ADD COLUMN parent_id + index.
* public/index.php — PATCH /api/buckets accepts parent_id with depth /
  cycle / collab guards. DELETE on a parent un-nests its children
  rather than cascade-deleting. The /api/import handler does a second
  pass to remap parent_id through the new bucket id map so backup +
  restore round-trips the hierarchy.
* public/js/modules/buckets.js renderBar() — top-level buckets only;
  buckets with ≥1 child render as a `.bucket-super` tile with
  data-bucket-super=<parent.id> and data-super-shown=<active member>.
  Three drop zones per tile: left/right 25% → reorder via insert
  cursor, center 50% → nest (PATCH parent_id) with .bucket-nest-target
  outline. canNestInto() mirrors the backend rules client-side.
  openSuperDropdown() renders a popover under the tile with parent +
  children as real .bucket-tab[data-bucket=…] entries (so the collab
  plugin's MutationObserver still picks them up); appended inside
  #bucket-bar but `position:fixed` so the bar's overflow:auto can't
  clip it. showBucketCtx() now scopes move-left/right to siblings
  (top-level peers OR same parent_id) so the action works in both
  the bar and the dropdown.

Drag-and-drop insertion cursor for cards (matches the bucket fix):

* public/js/modules/board.js bindCardEvents() — single
  #card-drop-cursor element inside #board, repositioned by per-card
  dragover. Three zones (left/right 25% → cursor in gap, center 50% →
  card-drop-group highlight). dragleave does NOT hide the cursor (was
  flickering as the mouse moved between adjacent cards); only dragend
  clears it. handleCardDrop recomputes the drop zone from e.clientX
  since the cursor approach no longer tags the target with
  .card-drop-before / .card-drop-after.
* "Vor der ersten Card" works automatically: the first card's left
  25% positions the cursor at offsetLeft - 6, which sits inside
  #board's px-6 padding and stays visible.
* public/css/style.css — old .card-drop-before::before /
  .card-drop-after::after pseudo-elements removed in favour of the
  #card-drop-cursor rule.

Drag ghost survives background polls:

* board.js draggingCardIds: Set<string> at module scope. dragstart
  fills it; dragend clears. renderBoard() re-applies the
  .card-dragging class to every member of the set after every render
  so caldav / collab polls calling _reloadAll mid-drag don't
  visually pop the source card back to its normal state.

Tests (public/plugins/tests/super-buckets.test.js):

* API parent_id: nest, un-nest, self-parent rejected, level-2
  rejected (bucket with own children, target is itself a child),
  DELETE un-nests, export → import preserves parent_id.
* Bar rendering: super-tile DOM markers, label = parent name when
  no child active, label = active child name when selected, popover
  opens with parent + children entries, click child sets activeBucket,
  click super-tile twice toggles popover closed, plain bucket stays
  plain.
* Drop cursor element exists.
* Both describe blocks reset state.activeBucket = 'all' in afterEach
  — the rendering tests set it to a child id and the leak was breaking
  downstream Card: Due Date & Done / Card: Grouping tests by filtering
  every newly-created card with bucket_id=null off the board.

chore: bump version to 0.9.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:55:03 +02:00

156 lines
6.3 KiB
PHP

<?php
class Database
{
private static ?PDO $pdo = null;
public static function connect(): PDO
{
if (self::$pdo === null) {
$dir = dirname(DB_PATH);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
self::$pdo = new PDO('sqlite:' . DB_PATH, null, null, [
PDO::ATTR_ERRMODE => 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
)
');
// Relay UID for bidirectional sync
if (!in_array('relay_uid', $colNames)) {
self::$pdo->exec('ALTER TABLE cards ADD COLUMN relay_uid TEXT DEFAULT NULL');
}
// Relay pushed message IDs (skip on own poll)
self::$pdo->exec('
CREATE TABLE IF NOT EXISTS relay_pushed (
relay_msg_id INTEGER PRIMARY KEY,
created_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'");
}
// Collaborative buckets: plugin-managed relay UID. NULL for local buckets.
if (!in_array('collab_uid', $bcolNames)) {
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN collab_uid TEXT DEFAULT NULL');
self::$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_buckets_collab_uid ON buckets(collab_uid) WHERE collab_uid IS NOT NULL');
}
// Super-buckets: 1-level nesting. parent_id = NULL for top-level
// (regular bucket OR super-bucket parent), or another bucket id for
// a child. Depth is enforced in the API layer (no grandchildren).
if (!in_array('parent_id', $bcolNames)) {
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
}
}
}