diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2da575232..93df5790b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -761,9 +761,13 @@ private function registerEventListeners(IRegistrationContext $context): void */ public function boot(IBootContext $context): void { - // Deep link registration is deferred to avoid circular DI resolution. - // DeepLinkRegistryService depends on RegisterMapper/SchemaMapper which - // trigger circular resolution chains when resolved during boot. - // Consuming apps register their patterns lazily on first use instead. + // Dispatch the deep link registration event so consuming apps + // (Procest, Pipelinq, etc.) can register their URL patterns. + // DeepLinkRegistryService uses ContainerInterface for lazy mapper + // resolution, so no circular DI issues during registration. + $server = $context->getServerContainer(); + $dispatcher = $server->get(IEventDispatcher::class); + $registry = $server->get(DeepLinkRegistryService::class); + $dispatcher->dispatchTyped(new DeepLinkRegistrationEvent(registry: $registry)); }//end boot() }//end class diff --git a/lib/Controller/TasksController.php b/lib/Controller/TasksController.php index b5c021550..272509084 100644 --- a/lib/Controller/TasksController.php +++ b/lib/Controller/TasksController.php @@ -107,8 +107,13 @@ public function index( } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); } catch (Exception $e) { + // No VTODO calendar = no tasks; return empty for listing. + if (str_contains($e->getMessage(), 'No VTODO-supporting calendar') === true) { + return new JSONResponse(data: ['results' => [], 'total' => 0]); + } + return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 500); - } + }//end try }//end index() /** diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index f0982f56b..93aeab497 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -1272,11 +1272,13 @@ private function buildUnionSelectPart( // Columns that don't exist use NULL::text AS placeholder. // Cast to text ensures type compatibility across schemas in UNION. // (e.g., one schema has 'type' as text, another as jsonb). + $existingColumns = []; foreach (array_keys($allPropertyColumns) as $columnName) { $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); $colExpr = "NULL::text AS {$quotedCol}"; if ($this->columnExistsInTable(tableName: $tableName, columnName: $columnName) === true) { - $colExpr = "{$quotedCol}::text AS {$quotedCol}"; + $colExpr = "{$quotedCol}::text AS {$quotedCol}"; + $existingColumns[] = $columnName; } $selectColumns[] = $colExpr; @@ -1301,7 +1303,14 @@ private function buildUnionSelectPart( $type = $propDef['type'] ?? 'string'; if (in_array($type, ['string', 'text'], true) === true) { $columnName = $this->sanitizeColumnName(name: $propName); - $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); + // Only score columns that actually exist in this table. + // Columns from other schemas are aliased as NULL in the SELECT + // and cannot be referenced by similarity()/ILIKE expressions. + if ($this->columnExistsInTable(tableName: $tableName, columnName: $columnName) === false) { + continue; + } + + $quotedCol = $this->quoteIdentifier(name: $columnName, isPostgres: $isPostgres); // Fallback: use CASE with ILIKE for basic relevance scoring. $likePattern = "'%".trim($quotedTerm, "'")."%'"; $scoreExpr = "CASE WHEN {$quotedCol}::text ILIKE {$likePattern} THEN 1 ELSE 0 END"; @@ -1312,7 +1321,7 @@ private function buildUnionSelectPart( $searchColumns[] = $scoreExpr; } - } + }//end foreach $selectColumns[] = '0 AS _search_score'; if (empty($searchColumns) === false) { @@ -1329,7 +1338,12 @@ private function buildUnionSelectPart( // Build WHERE conditions using shared method (single source of truth for filters). // This ensures search, count, and facets all use the same filter logic. - $whereClauses = $this->searchHandler->buildWhereConditionsSql(query: $query, schema: $schema); + // Pass existing columns so property-based search only targets columns in this table. + $whereClauses = $this->searchHandler->buildWhereConditionsSql( + query: $query, + schema: $schema, + existingColumns: $existingColumns + ); if (empty($whereClauses) === false) { $selectSql .= ' WHERE '.implode(' AND ', $whereClauses); diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index b4dd0fe42..ce9f99df0 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -355,12 +355,13 @@ function ($key) { * Includes RBAC filtering when enabled (default). Values are quoted inline * (not parameterized) for UNION query compatibility. * - * @param array $query Search parameters including filters. - * @param Schema $schema The schema for property filtering. + * @param array $query Search parameters including filters. + * @param Schema $schema The schema for property filtering. + * @param array|null $existingColumns Optional list of existing column names. * * @return string[] Array of SQL WHERE conditions (without leading AND/WHERE). */ - public function buildWhereConditionsSql(array $query, Schema $schema): array + public function buildWhereConditionsSql(array $query, Schema $schema, ?array $existingColumns=null): array { $conditions = []; // Get connection for value quoting through QueryBuilder. @@ -391,7 +392,8 @@ public function buildWhereConditionsSql(array $query, Schema $schema): array search: trim($search), schema: $schema, query: $query, - connection: $connection + connection: $connection, + existingColumns: $existingColumns ); if ($searchCondition !== null) { $conditions[] = $searchCondition; @@ -443,10 +445,11 @@ private function buildRbacConditionSql(Schema $schema): ?string * Without _fuzzy=true: ~140ms (ILIKE only) * With _fuzzy=true: ~160ms (ILIKE + similarity on _name) * - * @param string $search Trimmed search term - * @param Schema $schema Schema for determining searchable columns - * @param array $query Full query array for extracting _fuzzy param - * @param object $connection Database connection for value quoting + * @param string $search Trimmed search term + * @param Schema $schema Schema for determining searchable columns + * @param array $query Full query array for extracting _fuzzy param + * @param object $connection Database connection for value quoting + * @param array|null $existingColumns Optional list of existing column names. * * @return string|null SQL condition or null if no search conditions generated */ @@ -454,7 +457,8 @@ private function buildSearchConditionSql( string $search, Schema $schema, array $query, - object $connection + object $connection, + ?array $existingColumns=null ): ?string { $searchConditions = []; $likePattern = $connection->quote('%'.$search.'%'); @@ -468,7 +472,12 @@ private function buildSearchConditionSql( foreach ($properties as $propName => $propDef) { $type = $propDef['type'] ?? 'string'; if ($type === 'string') { - $columnName = $this->sanitizeColumnName(name: $propName); + $columnName = $this->sanitizeColumnName(name: $propName); + // In UNION contexts, only search columns that actually exist in this table. + if ($existingColumns !== null && in_array($columnName, $existingColumns, true) === false) { + continue; + } + $searchConditions[] = "{$columnName}::text ILIKE {$likePattern}"; } } diff --git a/lib/Service/NoteService.php b/lib/Service/NoteService.php index 39308b14c..3af4fecb6 100644 --- a/lib/Service/NoteService.php +++ b/lib/Service/NoteService.php @@ -147,6 +147,7 @@ public function createNote(string $objectUuid, string $message): array ); $comment->setMessage($message); + $comment->setVerb('comment'); $this->commentsManager->save($comment); return $this->commentToArray(comment: $comment);