0 and is_default = 0: * personal user prompt */ class PromptStoreException extends Exception { } class PromptRecord { private $row; public function __construct($row) { $this->row = is_array($row) ? $row : array(); } public function id() { return (int)($this->row['id'] ?? 0); } public function userId() { return (int)($this->row['user_id'] ?? 0); } public function isDefault() { return (int)($this->row['is_default'] ?? 0) === 1; } public function defaultKey() { return (string)($this->row['default_key'] ?? ''); } public function name() { return (string)($this->row['name'] ?? ''); } public function language() { return (string)($this->row['language'] ?? ''); } public function content() { return (string)($this->row['content'] ?? ''); } public function createdAt() { return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; } public function updatedAt() { return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null; } public function deletedAt() { return isset($this->row['deleted_at']) && $this->row['deleted_at'] !== null ? (int)$this->row['deleted_at'] : null; } public function raw() { return $this->row; } } class PromptVersionRecord { private $row; public function __construct($row) { $this->row = is_array($row) ? $row : array(); } public function id() { return (int)($this->row['id'] ?? 0); } public function promptId() { return (int)($this->row['prompt_id'] ?? 0); } public function userId() { return (int)($this->row['user_id'] ?? 0); } public function versionNo() { return (int)($this->row['version_no'] ?? 0); } public function name() { return (string)($this->row['name'] ?? ''); } public function language() { return (string)($this->row['language'] ?? ''); } public function content() { return (string)($this->row['content'] ?? ''); } public function note() { return (string)($this->row['note'] ?? ''); } public function createdAt() { return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; } public function raw() { return $this->row; } } class PromptStore { private $db; public function __construct($dbFile) { $dir = dirname($dbFile); if (!is_dir($dir)) { if (!mkdir($dir, 0700, true)) { throw new PromptStoreException('Could not create database directory: ' . $dir); } } $this->db = new PDO('sqlite:' . $dbFile); $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->db->exec('PRAGMA journal_mode = WAL'); $this->db->exec('PRAGMA busy_timeout = 5000'); $this->init(); } private function init() { /* * Canonical schema. * * Existing databases are migrated below. In particular, this version * does not require nullable user_id. Global defaults use user_id = 0. */ $this->db->exec( 'CREATE TABLE IF NOT EXISTS prompts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0, default_key TEXT NULL, name TEXT NOT NULL, language TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER NULL )' ); $this->migratePromptsTable(); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_prompts_user ON prompts(user_id, is_default, deleted_at, language, name)' ); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_prompts_default ON prompts(is_default, deleted_at, language, default_key)' ); $this->db->exec( 'CREATE TABLE IF NOT EXISTS prompt_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt_id INTEGER NOT NULL, user_id INTEGER NOT NULL DEFAULT 0, version_no INTEGER NOT NULL, name TEXT NOT NULL, language TEXT NOT NULL, content TEXT NOT NULL, note TEXT NOT NULL DEFAULT "", created_at INTEGER NOT NULL, FOREIGN KEY(prompt_id) REFERENCES prompts(id), UNIQUE(prompt_id, version_no) )' ); $this->migratePromptVersionsTable(); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt ON prompt_versions(prompt_id, version_no)' ); } private function migratePromptsTable() { $cols = $this->tableColumns('prompts'); if (!isset($cols['user_id'])) { $this->db->exec('ALTER TABLE prompts ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0'); } if (!isset($cols['is_default'])) { $this->db->exec('ALTER TABLE prompts ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0'); } if (!isset($cols['default_key'])) { $this->db->exec('ALTER TABLE prompts ADD COLUMN default_key TEXT NULL'); } if (!isset($cols['deleted_at'])) { $this->db->exec('ALTER TABLE prompts ADD COLUMN deleted_at INTEGER NULL'); } /* * Normalize existing rows. */ $this->db->exec( 'UPDATE prompts SET is_default = 0 WHERE is_default IS NULL' ); $this->db->exec( 'UPDATE prompts SET user_id = 0 WHERE is_default = 1' ); } private function migratePromptVersionsTable() { $cols = $this->tableColumns('prompt_versions'); if (!isset($cols['user_id'])) { $this->db->exec('ALTER TABLE prompt_versions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0'); } if (!isset($cols['note'])) { $this->db->exec('ALTER TABLE prompt_versions ADD COLUMN note TEXT NOT NULL DEFAULT ""'); } $this->db->exec( 'UPDATE prompt_versions SET user_id = 0 WHERE user_id IS NULL' ); } private function tableColumns($table) { $stmt = $this->db->query('PRAGMA table_info(' . $table . ')'); $cols = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $cols[$row['name']] = true; } return $cols; } public function supportedLanguages() { return array('en', 'nl'); } public function languageLabel($language) { if ($language === 'nl') { return 'Nederlands'; } if ($language === 'en') { return 'English'; } return $language; } /* ------------------------------------------------------------------ */ /* Personal prompts */ /* ------------------------------------------------------------------ */ public function listPrompts($userId, $language = null) { $userId = $this->safeUserId($userId); if ($language !== null && $language !== '') { $language = $this->safeLanguage($language); $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE user_id = :user_id AND is_default = 0 AND deleted_at IS NULL AND language = :language ORDER BY name COLLATE NOCASE' ); $stmt->execute(array( ':user_id' => $userId, ':language' => $language, )); } else { $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE user_id = :user_id AND is_default = 0 AND deleted_at IS NULL ORDER BY language, name COLLATE NOCASE' ); $stmt->execute(array(':user_id' => $userId)); } return $this->promptRows($stmt); } public function getPrompt($userId, $promptId) { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE id = :id AND user_id = :user_id AND is_default = 0 AND deleted_at IS NULL' ); $stmt->execute(array( ':id' => $promptId, ':user_id' => $userId, )); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? new PromptRecord($row) : null; } public function createPrompt($userId, $name, $language, $content) { $userId = $this->safeUserId($userId); $name = $this->safeName($name); $language = $this->safeLanguage($language); $content = $this->safeContent($content); $now = time(); $stmt = $this->db->prepare( 'INSERT INTO prompts (user_id, is_default, default_key, name, language, content, created_at, updated_at, deleted_at) VALUES (:user_id, 0, NULL, :name, :language, :content, :created_at, :updated_at, NULL)' ); $stmt->execute(array( ':user_id' => $userId, ':name' => $name, ':language' => $language, ':content' => $content, ':created_at' => $now, ':updated_at' => $now, )); $promptId = (int)$this->db->lastInsertId(); $this->createVersionForPrompt($promptId, $userId, 'initial version'); return $this->getPrompt($userId, $promptId); } public function updatePrompt($userId, $promptId, $name, $language, $content, $createVersion, $note = '') { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); $name = $this->safeName($name); $language = $this->safeLanguage($language); $content = $this->safeContent($content); $prompt = $this->getPrompt($userId, $promptId); if ($prompt === null) { throw new PromptStoreException('Prompt not found.'); } $stmt = $this->db->prepare( 'UPDATE prompts SET name = :name, language = :language, content = :content, updated_at = :updated_at WHERE id = :id AND user_id = :user_id AND is_default = 0 AND deleted_at IS NULL' ); $stmt->execute(array( ':name' => $name, ':language' => $language, ':content' => $content, ':updated_at' => time(), ':id' => $promptId, ':user_id' => $userId, )); if ($createVersion) { $this->createVersionForPrompt($promptId, $userId, $note); } return $this->getPrompt($userId, $promptId); } public function deletePrompt($userId, $promptId) { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); $stmt = $this->db->prepare( 'UPDATE prompts SET deleted_at = :deleted_at, updated_at = :updated_at WHERE id = :id AND user_id = :user_id AND is_default = 0 AND deleted_at IS NULL' ); $stmt->execute(array( ':deleted_at' => time(), ':updated_at' => time(), ':id' => $promptId, ':user_id' => $userId, )); } /* ------------------------------------------------------------------ */ /* Default prompts */ /* ------------------------------------------------------------------ */ public function listDefaultPrompts($language = null) { if ($language !== null && $language !== '') { $language = $this->safeLanguage($language); $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE user_id = 0 AND is_default = 1 AND deleted_at IS NULL AND language = :language ORDER BY name COLLATE NOCASE' ); $stmt->execute(array(':language' => $language)); } else { $stmt = $this->db->query( 'SELECT * FROM prompts WHERE user_id = 0 AND is_default = 1 AND deleted_at IS NULL ORDER BY language, name COLLATE NOCASE' ); } return $this->promptRows($stmt); } public function getDefaultPrompt($promptId) { $promptId = $this->safeId($promptId); $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE id = :id AND user_id = 0 AND is_default = 1 AND deleted_at IS NULL' ); $stmt->execute(array(':id' => $promptId)); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? new PromptRecord($row) : null; } public function createDefaultPrompt($defaultKey, $name, $language, $content) { $defaultKey = $this->safeDefaultKey($defaultKey); $name = $this->safeName($name); $language = $this->safeLanguage($language); $content = $this->safeContent($content); $now = time(); $stmt = $this->db->prepare( 'INSERT INTO prompts (user_id, is_default, default_key, name, language, content, created_at, updated_at, deleted_at) VALUES (0, 1, :default_key, :name, :language, :content, :created_at, :updated_at, NULL)' ); $stmt->execute(array( ':default_key' => $defaultKey, ':name' => $name, ':language' => $language, ':content' => $content, ':created_at' => $now, ':updated_at' => $now, )); $promptId = (int)$this->db->lastInsertId(); $this->createVersionForPrompt($promptId, 0, 'initial default version'); return $this->getDefaultPrompt($promptId); } public function updateDefaultPrompt($promptId, $defaultKey, $name, $language, $content, $createVersion, $note = '') { $promptId = $this->safeId($promptId); $defaultKey = $this->safeDefaultKey($defaultKey); $name = $this->safeName($name); $language = $this->safeLanguage($language); $content = $this->safeContent($content); $prompt = $this->getDefaultPrompt($promptId); if ($prompt === null) { throw new PromptStoreException('Default prompt not found.'); } $stmt = $this->db->prepare( 'UPDATE prompts SET default_key = :default_key, name = :name, language = :language, content = :content, updated_at = :updated_at WHERE id = :id AND user_id = 0 AND is_default = 1 AND deleted_at IS NULL' ); $stmt->execute(array( ':default_key' => $defaultKey, ':name' => $name, ':language' => $language, ':content' => $content, ':updated_at' => time(), ':id' => $promptId, )); if ($createVersion) { $this->createVersionForPrompt($promptId, 0, $note); } return $this->getDefaultPrompt($promptId); } public function deleteDefaultPrompt($promptId) { $promptId = $this->safeId($promptId); $stmt = $this->db->prepare( 'UPDATE prompts SET deleted_at = :deleted_at, updated_at = :updated_at WHERE id = :id AND user_id = 0 AND is_default = 1 AND deleted_at IS NULL' ); $stmt->execute(array( ':deleted_at' => time(), ':updated_at' => time(), ':id' => $promptId, )); } public function copyDefaultPromptToUser($userId, $defaultPromptId) { $userId = $this->safeUserId($userId); $defaultPromptId = $this->safeId($defaultPromptId); $default = $this->getDefaultPrompt($defaultPromptId); if ($default === null) { throw new PromptStoreException('Default prompt not found.'); } $now = time(); $stmt = $this->db->prepare( 'INSERT INTO prompts (user_id, is_default, default_key, name, language, content, created_at, updated_at, deleted_at) VALUES (:user_id, 0, :default_key, :name, :language, :content, :created_at, :updated_at, NULL)' ); $stmt->execute(array( ':user_id' => $userId, ':default_key' => $default->defaultKey(), ':name' => $default->name(), ':language' => $default->language(), ':content' => $default->content(), ':created_at' => $now, ':updated_at' => $now, )); $promptId = (int)$this->db->lastInsertId(); $this->createVersionForPrompt($promptId, $userId, 'copied from default ' . $default->defaultKey()); return $this->getPrompt($userId, $promptId); } public function copyAllDefaultsToUser($userId, $language) { $userId = $this->safeUserId($userId); $language = $this->safeLanguage($language); $defaults = $this->listDefaultPrompts($language); $created = 0; foreach ($defaults as $default) { if ($this->userHasPromptForDefault($userId, $default->defaultKey(), $language)) { continue; } $this->copyDefaultPromptToUser($userId, $default->id()); $created++; } return $created; } public function userHasPromptForDefault($userId, $defaultKey, $language) { $userId = $this->safeUserId($userId); $defaultKey = $this->safeDefaultKey($defaultKey); $language = $this->safeLanguage($language); $stmt = $this->db->prepare( 'SELECT id FROM prompts WHERE user_id = :user_id AND is_default = 0 AND deleted_at IS NULL AND default_key = :default_key AND language = :language' ); $stmt->execute(array( ':user_id' => $userId, ':default_key' => $defaultKey, ':language' => $language, )); return $stmt->fetch(PDO::FETCH_ASSOC) ? true : false; } /* ------------------------------------------------------------------ */ /* Versions */ /* ------------------------------------------------------------------ */ public function createVersion($userId, $promptId, $note = '') { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); if ($this->getPrompt($userId, $promptId) === null) { throw new PromptStoreException('Prompt not found.'); } return $this->createVersionForPrompt($promptId, $userId, $note); } public function createDefaultVersion($promptId, $note = '') { $promptId = $this->safeId($promptId); if ($this->getDefaultPrompt($promptId) === null) { throw new PromptStoreException('Default prompt not found.'); } return $this->createVersionForPrompt($promptId, 0, $note); } public function listVersions($userId, $promptId) { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); if ($this->getPrompt($userId, $promptId) === null) { throw new PromptStoreException('Prompt not found.'); } return $this->listVersionsForPrompt($promptId); } public function listDefaultVersions($promptId) { $promptId = $this->safeId($promptId); if ($this->getDefaultPrompt($promptId) === null) { throw new PromptStoreException('Default prompt not found.'); } return $this->listVersionsForPrompt($promptId); } public function restoreVersion($userId, $promptId, $versionNo) { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); if ($this->getPrompt($userId, $promptId) === null) { throw new PromptStoreException('Prompt not found.'); } $this->restoreVersionForPrompt($promptId, $versionNo, $userId); } public function restoreDefaultVersion($promptId, $versionNo) { $promptId = $this->safeId($promptId); if ($this->getDefaultPrompt($promptId) === null) { throw new PromptStoreException('Default prompt not found.'); } $this->restoreVersionForPrompt($promptId, $versionNo, 0); } private function createVersionForPrompt($promptId, $userId, $note = '') { $promptId = $this->safeId($promptId); $userId = (int)$userId; $note = trim((string)$note); if (strlen($note) > 500) { $note = substr($note, 0, 500); } $stmt = $this->db->prepare( 'SELECT * FROM prompts WHERE id = :id AND deleted_at IS NULL' ); $stmt->execute(array(':id' => $promptId)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new PromptStoreException('Prompt not found.'); } $stmt = $this->db->prepare( 'SELECT COALESCE(MAX(version_no), 0) + 1 AS next_no FROM prompt_versions WHERE prompt_id = :prompt_id' ); $stmt->execute(array(':prompt_id' => $promptId)); $versionRow = $stmt->fetch(PDO::FETCH_ASSOC); $versionNo = (int)$versionRow['next_no']; $stmt = $this->db->prepare( 'INSERT INTO prompt_versions (prompt_id, user_id, version_no, name, language, content, note, created_at) VALUES (:prompt_id, :user_id, :version_no, :name, :language, :content, :note, :created_at)' ); $stmt->execute(array( ':prompt_id' => $promptId, ':user_id' => $userId, ':version_no' => $versionNo, ':name' => $row['name'], ':language' => $row['language'], ':content' => $row['content'], ':note' => $note, ':created_at' => time(), )); return $versionNo; } private function listVersionsForPrompt($promptId) { $promptId = $this->safeId($promptId); $stmt = $this->db->prepare( 'SELECT * FROM prompt_versions WHERE prompt_id = :prompt_id ORDER BY version_no DESC' ); $stmt->execute(array(':prompt_id' => $promptId)); $out = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $out[] = new PromptVersionRecord($row); } return $out; } private function restoreVersionForPrompt($promptId, $versionNo, $userId) { $promptId = $this->safeId($promptId); $versionNo = (int)$versionNo; $userId = (int)$userId; if ($versionNo <= 0) { throw new PromptStoreException('Invalid version number.'); } $stmt = $this->db->prepare( 'SELECT * FROM prompt_versions WHERE prompt_id = :prompt_id AND version_no = :version_no' ); $stmt->execute(array( ':prompt_id' => $promptId, ':version_no' => $versionNo, )); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new PromptStoreException('Version not found.'); } $stmt = $this->db->prepare( 'UPDATE prompts SET name = :name, language = :language, content = :content, updated_at = :updated_at WHERE id = :id AND deleted_at IS NULL' ); $stmt->execute(array( ':name' => $row['name'], ':language' => $row['language'], ':content' => $row['content'], ':updated_at' => time(), ':id' => $promptId, )); $this->createVersionForPrompt($promptId, $userId, 'restored version ' . $versionNo); } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ private function promptRows($stmt) { $out = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $out[] = new PromptRecord($row); } return $out; } private function safeUserId($userId) { $userId = (int)$userId; if ($userId <= 0) { throw new PromptStoreException('Invalid user id.'); } return $userId; } private function safeId($id) { $id = (int)$id; if ($id <= 0) { throw new PromptStoreException('Invalid id.'); } return $id; } private function safeName($name) { $name = trim((string)$name); if ($name === '') { throw new PromptStoreException('Prompt name is required.'); } if (strlen($name) > 160) { throw new PromptStoreException('Prompt name is too long.'); } return $name; } private function safeLanguage($language) { $language = strtolower(trim((string)$language)); if (!in_array($language, $this->supportedLanguages(), true)) { throw new PromptStoreException('Unsupported language: ' . $language); } return $language; } private function safeContent($content) { $content = trim((string)$content); if ($content === '') { throw new PromptStoreException('Prompt content is required.'); } if (strlen($content) > 200000) { throw new PromptStoreException('Prompt content is too large.'); } return $content; } private function safeDefaultKey($key) { $key = trim((string)$key); if ($key === '') { throw new PromptStoreException('Default key is required.'); } if (!preg_match('/^[A-Za-z0-9_-]+$/', $key)) { throw new PromptStoreException('Invalid default key.'); } return $key; } public function deleteVersion($userId, $promptId, $versionNo) { $userId = $this->safeUserId($userId); $promptId = $this->safeId($promptId); $versionNo = (int)$versionNo; if ($versionNo <= 0) { throw new PromptStoreException('Invalid version number.'); } if ($this->getPrompt($userId, $promptId) === null) { throw new PromptStoreException('Prompt not found.'); } $this->deleteVersionForPrompt($promptId, $versionNo); } public function deleteDefaultVersion($promptId, $versionNo) { $promptId = $this->safeId($promptId); $versionNo = (int)$versionNo; if ($versionNo <= 0) { throw new PromptStoreException('Invalid version number.'); } if ($this->getDefaultPrompt($promptId) === null) { throw new PromptStoreException('Default prompt not found.'); } $this->deleteVersionForPrompt($promptId, $versionNo); } private function deleteVersionForPrompt($promptId, $versionNo) { $promptId = $this->safeId($promptId); $versionNo = (int)$versionNo; $stmt = $this->db->prepare( 'SELECT COUNT(*) AS n FROM prompt_versions WHERE prompt_id = :prompt_id' ); $stmt->execute(array(':prompt_id' => $promptId)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || (int)$row['n'] <= 1) { throw new PromptStoreException('Cannot delete the last remaining version.'); } $stmt = $this->db->prepare( 'DELETE FROM prompt_versions WHERE prompt_id = :prompt_id AND version_no = :version_no' ); $stmt->execute(array( ':prompt_id' => $promptId, ':version_no' => $versionNo, )); if ($stmt->rowCount() !== 1) { throw new PromptStoreException('Version not found.'); } } }