Просмотр исходного кода

AI search: judge models (TypeSafe Jev) and natural language search (#5721)

* AI search: judge models (TypeSafe Jev) and natural language search

* fix multibyte funcs

* fix fields

* cache key with viewer

* get rid of hardcoded modules list

---------

Co-authored-by: Aleksandr Trofimov <at@unacms.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
RomanL 2 недель назад
Родитель
Сommit
0d9777edb3
100 измененных файлов с 2933 добавлено и 873 удалено
  1. 74 24
      inc/classes/BxDolAIModelFactory.php
  2. 238 0
      inc/classes/BxDolAiJudge.php
  3. 132 0
      inc/classes/BxDolAiJudgeTypeSafe.php
  4. 863 0
      inc/classes/BxDolAiSearchExtParser.php
  5. 465 0
      inc/classes/BxDolAiSearchParser.php
  6. 283 0
      inc/classes/BxDolAiSearchRerank.php
  7. 22 7
      inc/classes/BxDolDb.php
  8. 20 10
      install/sql/system.sql
  9. 1 1
      modules/boonex/accounts/install/config.php
  10. 1 1
      modules/boonex/acl/install/config.php
  11. 1 1
      modules/boonex/ads/install/config.php
  12. 1 1
      modules/boonex/albums/install/config.php
  13. 77 77
      modules/boonex/analytics/install/config.php
  14. 1 1
      modules/boonex/anon_follow/install/config.php
  15. 1 1
      modules/boonex/antispam/install/config.php
  16. 1 1
      modules/boonex/api/install/config.php
  17. 1 1
      modules/boonex/artificer/install/config.php
  18. 1 1
      modules/boonex/attendant/install/config.php
  19. 1 1
      modules/boonex/azure_b2c_con/install/config.php
  20. 1 1
      modules/boonex/azure_connect/install/config.php
  21. 1 1
      modules/boonex/cas_connect/install/config.php
  22. 153 153
      modules/boonex/channels/install/config.php
  23. 77 77
      modules/boonex/charts/install/config.php
  24. 1 1
      modules/boonex/chat_plus/install/config.php
  25. 1 1
      modules/boonex/cidaas_connect/install/config.php
  26. 1 1
      modules/boonex/classes/install/config.php
  27. 1 1
      modules/boonex/contact/install/config.php
  28. 1 1
      modules/boonex/convos/install/config.php
  29. 1 1
      modules/boonex/courses/install/config.php
  30. 1 1
      modules/boonex/credits/install/config.php
  31. 1 1
      modules/boonex/datafox/install/config.php
  32. 1 1
      modules/boonex/decorous/install/config.php
  33. 1 1
      modules/boonex/developer/install/config.php
  34. 1 1
      modules/boonex/dolphin_connect/install/config.php
  35. 1 1
      modules/boonex/dolphin_migration/install/config.php
  36. 1 1
      modules/boonex/donations/install/config.php
  37. 1 1
      modules/boonex/drupal_connect/install/config.php
  38. 1 1
      modules/boonex/editor/install/config.php
  39. 1 1
      modules/boonex/elasticsearch/install/config.php
  40. 2 0
      modules/boonex/english/data/langs/system/en.xml
  41. 1 1
      modules/boonex/english/install/config.php
  42. 1 1
      modules/boonex/events/install/config.php
  43. 1 1
      modules/boonex/facebook_connect/install/config.php
  44. 1 1
      modules/boonex/feedback/install/config.php
  45. 1 1
      modules/boonex/files/install/config.php
  46. 1 1
      modules/boonex/fontawesome/install/config.php
  47. 1 1
      modules/boonex/forum/install/config.php
  48. 1 1
      modules/boonex/froala/install/config.php
  49. 1 1
      modules/boonex/github/install/config.php
  50. 136 136
      modules/boonex/glossary/install/config.php
  51. 1 1
      modules/boonex/google_connect/install/config.php
  52. 1 1
      modules/boonex/google_tagmanager/install/config.php
  53. 1 1
      modules/boonex/groups/install/config.php
  54. 1 1
      modules/boonex/help_tours/install/config.php
  55. 1 1
      modules/boonex/intercom/install/config.php
  56. 1 1
      modules/boonex/invites/install/config.php
  57. 1 1
      modules/boonex/jobs/install/config.php
  58. 1 1
      modules/boonex/linkedin_connect/install/config.php
  59. 1 1
      modules/boonex/lucid/install/config.php
  60. 1 1
      modules/boonex/mailchimp/install/config.php
  61. 77 77
      modules/boonex/mapshow/install/config.php
  62. 1 1
      modules/boonex/markerio/install/config.php
  63. 1 1
      modules/boonex/market/install/config.php
  64. 1 1
      modules/boonex/massmailer/install/config.php
  65. 1 1
      modules/boonex/new_comments/install/config.php
  66. 1 1
      modules/boonex/nexus/install/config.php
  67. 1 1
      modules/boonex/notifications/install/config.php
  68. 1 1
      modules/boonex/oauth2/install/config.php
  69. 1 1
      modules/boonex/ocean/install/config.php
  70. 1 1
      modules/boonex/okta_connect/install/config.php
  71. 1 1
      modules/boonex/opencv/install/config.php
  72. 1 1
      modules/boonex/organizations/install/config.php
  73. 1 1
      modules/boonex/payment/install/config.php
  74. 1 1
      modules/boonex/persons/install/config.php
  75. 1 1
      modules/boonex/photos/install/config.php
  76. 1 1
      modules/boonex/plyr/install/config.php
  77. 1 1
      modules/boonex/polls/install/config.php
  78. 1 1
      modules/boonex/posts/install/config.php
  79. 1 1
      modules/boonex/profiler/install/config.php
  80. 1 1
      modules/boonex/protean/install/config.php
  81. 77 77
      modules/boonex/quoteofday/install/config.php
  82. 1 1
      modules/boonex/reminders/install/config.php
  83. 1 1
      modules/boonex/reputation/install/config.php
  84. 1 1
      modules/boonex/resources/install/config.php
  85. 1 1
      modules/boonex/reviews/install/config.php
  86. 2 0
      modules/boonex/russian/data/langs/system/ru.xml
  87. 1 1
      modules/boonex/russian/install/config.php
  88. 1 1
      modules/boonex/se_migration/install/config.php
  89. 1 1
      modules/boonex/shopify/install/config.php
  90. 1 1
      modules/boonex/smtpmailer/install/config.php
  91. 1 1
      modules/boonex/snipcart/install/config.php
  92. 152 152
      modules/boonex/spaces/install/config.php
  93. 1 1
      modules/boonex/stories/install/config.php
  94. 1 1
      modules/boonex/stream/install/config.php
  95. 1 1
      modules/boonex/stripe_connect/install/config.php
  96. 1 1
      modules/boonex/tasks/install/config.php
  97. 1 1
      modules/boonex/timeline/install/config.php
  98. 1 1
      modules/boonex/twitter_connect/install/config.php
  99. 1 1
      modules/boonex/una_connect/install/config.php
  100. 1 1
      modules/boonex/videos/install/config.php

+ 74 - 24
inc/classes/BxDolAIModelFactory.php

@@ -9,6 +9,10 @@
 
 class BxDolAIModelFactory extends BxDolFactory
 {
+    /**
+     * Chat (chatllm / chatvlm) and embeddings models as NeuronAI providers.
+     * Judge models (capabilities = judge) are not NeuronAI providers, use getJudgeInstance().
+     */
     public static function getModelInstance(int $iId, array $aOverrides = []): NeuronAI\Providers\AIProviderInterface | NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
     {
         $iMaxTokensOverride = (int)($aOverrides['max_tokens'] ?? 0);
@@ -16,33 +20,13 @@ class BxDolAIModelFactory extends BxDolFactory
         if (isset($GLOBALS['bxDolClasses'][$sCacheKey]))
             return $GLOBALS['bxDolClasses'][$sCacheKey];
 
-        $aProvidersWithKey = ['anthropic', 'openai-embeddings', 'voyageai-embeddings', 'openai-like-embeddings', 'openai-responses', 'openai-like'];
-        $a = BxDolAiQuery::getModelObject($iId);
-        if (!$a) {
-            bx_log('sys_agents', "Agent AI Model with id {$iId} not found", BX_LOG_ERR);
-            throw new Exception("Agent AI Model with id {$iId} not found");
-        }
-        $a['key'] = (string)$a['key'];
-        if (in_array($a['type'], $aProvidersWithKey, true) && $a['key'] === '') {
-            bx_log('sys_agents', "Model with id {$iId} has empty key, can't be used", BX_LOG_ERR);
-            throw new Exception("Model with id {$iId} has empty key, can't be used");
-        }
+        list($a, $aParameters) = self::_getModel($iId);
 
-        if (!$a['active']) {
-            bx_log('sys_agents', "Model with id {$iId} is not active, can't be used", BX_LOG_ERR);
-            throw new Exception("Model with id {$iId} is not active, can't be used");
+        if ($a['capabilities'] == BxDolAiJudge::CAPABILITY) {
+            bx_log('sys_agents', "Model with id {$iId} is a judge model, it can't be used as a chat/embeddings provider", BX_LOG_ERR);
+            throw new Exception("Model with id {$iId} is a judge model, it can't be used as a chat/embeddings provider");
         }
 
-        $aParametersSystem = !empty($a['params']) ? json_decode($a['params'], true) : [];
-        $aParametersUser = !empty($a['params_user']) ? json_decode($a['params_user'], true) : [];
-        $aParameters = array_merge($aParametersSystem, $aParametersUser);
-
-        // replace markers {key} {model} in $aParameters recoursively
-        $aParameters = bx_replace_markers($aParameters, [
-            'key' => $a['key'],
-            'model' => $a['model']
-        ]);
-
         if ($iMaxTokensOverride > 0) {
             $aParameters['max_tokens'] = $iMaxTokensOverride;
             if (!isset($aParameters['parameters']) || !is_array($aParameters['parameters']))
@@ -226,4 +210,70 @@ class BxDolAIModelFactory extends BxDolFactory
 
         return $o;
     }
+
+    /**
+     * Judge models (capabilities = judge): typed questions -> structured answers, see BxDolAiJudge.
+     */
+    public static function getJudgeInstance(int $iId): BxDolAiJudge
+    {
+        $sCacheKey = __CLASS__ . '_Judge_' . $iId;
+        if (isset($GLOBALS['bxDolClasses'][$sCacheKey]))
+            return $GLOBALS['bxDolClasses'][$sCacheKey];
+
+        list($a, $aParameters) = self::_getModel($iId);
+
+        if ($a['capabilities'] != BxDolAiJudge::CAPABILITY) {
+            bx_log('sys_agents', "Model with id {$iId} is not a judge model", BX_LOG_ERR);
+            throw new Exception("Model with id {$iId} is not a judge model");
+        }
+
+        switch($a['type']) {
+            case 'typesafe':
+                $o = new BxDolAiJudgeTypeSafe($a, $aParameters);
+                break;
+            default:
+                bx_log('sys_agents', "Judge model type {$a['type']} is not supported", BX_LOG_ERR);
+                throw new Exception("Judge model type {$a['type']} is not supported");
+        }
+
+        $GLOBALS['bxDolClasses'][$sCacheKey] = $o;
+
+        return $o;
+    }
+
+    /**
+     * Load model row and its merged params; throws when the model is missing, inactive or has no key.
+     * @return array [model row, params]
+     */
+    protected static function _getModel(int $iId): array
+    {
+        $aProvidersWithKey = ['anthropic', 'openai-embeddings', 'voyageai-embeddings', 'openai-like-embeddings', 'openai-responses', 'openai-like', 'typesafe'];
+        $a = BxDolAiQuery::getModelObject($iId);
+        if (!$a) {
+            bx_log('sys_agents', "Agent AI Model with id {$iId} not found", BX_LOG_ERR);
+            throw new Exception("Agent AI Model with id {$iId} not found");
+        }
+        $a['key'] = (string)$a['key'];
+        if (in_array($a['type'], $aProvidersWithKey, true) && $a['key'] === '') {
+            bx_log('sys_agents', "Model with id {$iId} has empty key, can't be used", BX_LOG_ERR);
+            throw new Exception("Model with id {$iId} has empty key, can't be used");
+        }
+
+        if (!$a['active']) {
+            bx_log('sys_agents', "Model with id {$iId} is not active, can't be used", BX_LOG_ERR);
+            throw new Exception("Model with id {$iId} is not active, can't be used");
+        }
+
+        $aParametersSystem = !empty($a['params']) ? json_decode($a['params'], true) : [];
+        $aParametersUser = !empty($a['params_user']) ? json_decode($a['params_user'], true) : [];
+        $aParameters = array_merge(is_array($aParametersSystem) ? $aParametersSystem : [], is_array($aParametersUser) ? $aParametersUser : []);
+
+        // replace markers {key} {model} in $aParameters recoursively
+        $aParameters = bx_replace_markers($aParameters, [
+            'key' => $a['key'],
+            'model' => $a['model']
+        ]);
+
+        return [$a, $aParameters];
+    }
 }

+ 238 - 0
inc/classes/BxDolAiJudge.php

@@ -0,0 +1,238 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * "Judge" AI models (capabilities = judge).
+ *
+ * Unlike chat (chatllm/chatvlm) and embeddings models a judge does not generate text.
+ * It evaluates typed questions against a state (string / array) and returns structured,
+ * machine-readable answers: booleans as probabilities (noul), a choice from a fixed list
+ * with a probability distribution (choice) or a score on a rubric (score).
+ *
+ * Judge models are NOT NeuronAI providers and can not be assigned to agents;
+ * use them from code: moderation, guardrails, intent routing, search query parsing, etc.
+ *
+ * Usage:
+ * @code
+ *  $oJudge = BxDolAiJudge::getInstance(); // default (first active) judge model, or getInstance($iModelId)
+ *  $aAnswers = $oJudge->ask($sText, [
+ *      'spam' => BxDolAiJudge::noul('Is this message unsolicited advertising?'),
+ *      'topic' => BxDolAiJudge::choice('What is the message about?', ['billing' => 'Payments', 'support' => 'Bugs, help requests', 'other' => 'Anything else']),
+ *      'anger' => BxDolAiJudge::score('How angry is the author?', ['Calm', 'Irritated', 'Furious']),
+ *  ]);
+ *  // $aAnswers['spam']['noul'] => 0.93
+ *  // $aAnswers['topic']['choice'] => 'support', ['confidence'] => 0.81, ['probabilities'] => [...]
+ *  // $aAnswers['anger']['score'] => 1, ['confidence'] => 0.9
+ * @endcode
+ */
+abstract class BxDolAiJudge extends BxDolFactory
+{
+    const CAPABILITY = 'judge';
+
+    const Q_NOUL = 'noul';
+    const Q_CHOICE = 'choice';
+    const Q_SCORE = 'score';
+
+    protected $_aModel;
+    protected $_aParams;
+    protected $_aLastUsage = [];
+    protected $_sLastModel = '';
+    protected $_sLastError = '';
+    protected $_aLastRequest = [];
+    protected $_aLastAnswers = [];
+
+    public function __construct(array $aModel, array $aParams)
+    {
+        $this->_aModel = $aModel;
+        $this->_aParams = $aParams;
+    }
+
+    /**
+     * Get judge instance by model id; when id is empty the first active judge model is used.
+     * Returns false when no judge model is configured/active (errors are logged).
+     */
+    public static function getInstance(int $iModelId = 0)
+    {
+        if (!$iModelId)
+            $iModelId = self::getDefaultModelId();
+
+        if (!$iModelId) {
+            bx_log('sys_agents', "No active judge AI model is configured", BX_LOG_WARN);
+            return false;
+        }
+
+        try {
+            return BxDolAIModelFactory::getJudgeInstance($iModelId);
+        }
+        catch (Exception $oException) {
+            bx_log('sys_agents', "Judge AI model {$iModelId} can't be instantiated: " . $oException->getMessage(), BX_LOG_ERR);
+            return false;
+        }
+    }
+
+    /**
+     * Id of the first active model with capabilities = judge, 0 if none.
+     */
+    public static function getDefaultModelId(): int
+    {
+        $aPairs = BxDolAi::getInstance()->getModels(['active' => true, 'capabilities' => self::CAPABILITY]);
+        if (empty($aPairs) || !is_array($aPairs))
+            return 0;
+
+        reset($aPairs);
+        return (int)key($aPairs);
+    }
+
+    public function getModel(): array
+    {
+        return $this->_aModel;
+    }
+
+    public function getModelId(): int
+    {
+        return (int)$this->_aModel['id'];
+    }
+
+    /**
+     * Token usage reported by the provider for the last ask() call: ['input_tokens' => N, 'output_tokens' => N]
+     */
+    public function getLastUsage(): array
+    {
+        return $this->_aLastUsage;
+    }
+
+    /**
+     * Exact model version reported by the provider for the last ask() call (e.g. jev-1.13.0)
+     */
+    public function getLastModel(): string
+    {
+        return $this->_sLastModel;
+    }
+
+    /**
+     * Request body sent to the provider by the last ask() call (for debugging)
+     */
+    public function getLastRequest(): array
+    {
+        return $this->_aLastRequest;
+    }
+
+    /**
+     * Raw answers returned by the provider for the last ask() call (for debugging)
+     */
+    public function getLastAnswers(): array
+    {
+        return $this->_aLastAnswers;
+    }
+
+    /**
+     * Error message of the last failed askSafe() call, '' when it succeeded
+     */
+    public function getLastError(): string
+    {
+        return $this->_sLastError;
+    }
+
+    // question builders ------------------------
+
+    /**
+     * Yes/no question, answered as a probability 0..1
+     * @param $mixedInstructions string|array question text (or structured instructions)
+     * @param $aCriteria optional ['true' => 'what counts as yes', 'false' => 'what counts as no']
+     */
+    public static function noul($mixedInstructions, array $aCriteria = []): array
+    {
+        $a = ['type' => self::Q_NOUL, 'instructions' => $mixedInstructions];
+        if ($aCriteria)
+            $a['criteria'] = $aCriteria;
+        return $a;
+    }
+
+    /**
+     * Pick one option, answered as choice + probabilities + confidence
+     * @param $mixedInstructions string|array question text
+     * @param $aCriteria ['option_key' => 'description', ...] (2..255 options)
+     */
+    public static function choice($mixedInstructions, array $aCriteria): array
+    {
+        return ['type' => self::Q_CHOICE, 'instructions' => $mixedInstructions, 'criteria' => $aCriteria];
+    }
+
+    /**
+     * Rate on a rubric, answered as score (index of level, 0-based) + probabilities + confidence
+     * @param $mixedInstructions string|array question text
+     * @param $aCriteria list of 2..10 level descriptions, from lowest to highest
+     */
+    public static function score($mixedInstructions, array $aCriteria): array
+    {
+        return ['type' => self::Q_SCORE, 'instructions' => $mixedInstructions, 'criteria' => array_values($aCriteria)];
+    }
+
+    // shortcuts ------------------------
+
+    /**
+     * Ask a single yes/no question.
+     * @return float|null probability 0..1 or null on failure
+     */
+    public function isTrue($mixedState, $mixedInstructions, array $aCriteria = [])
+    {
+        $aAnswers = $this->askSafe($mixedState, ['q' => self::noul($mixedInstructions, $aCriteria)]);
+        return isset($aAnswers['q']['noul']) ? (float)$aAnswers['q']['noul'] : null;
+    }
+
+    /**
+     * Ask a single choice question.
+     * @return array|null ['choice' => key, 'confidence' => 0..1, 'probabilities' => [...]] or null on failure
+     */
+    public function pick($mixedState, $mixedInstructions, array $aCriteria)
+    {
+        $aAnswers = $this->askSafe($mixedState, ['q' => self::choice($mixedInstructions, $aCriteria)]);
+        return isset($aAnswers['q']['choice']) ? $aAnswers['q'] : null;
+    }
+
+    /**
+     * Ask a single score question.
+     * @return array|null ['score' => int, 'confidence' => 0..1, 'probabilities' => [...], 'legend' => [...]] or null on failure
+     */
+    public function rate($mixedState, $mixedInstructions, array $aCriteria)
+    {
+        $aAnswers = $this->askSafe($mixedState, ['q' => self::score($mixedInstructions, $aCriteria)]);
+        return isset($aAnswers['q']['score']) ? $aAnswers['q'] : null;
+    }
+
+    /**
+     * Same as ask() but never throws: logs the error and returns empty array.
+     */
+    public function askSafe($mixedState, array $aQuestions): array
+    {
+        $this->_sLastError = '';
+        try {
+            return $this->ask($mixedState, $aQuestions);
+        }
+        catch (Exception $oException) {
+            $this->_sLastError = $oException->getMessage();
+            bx_log('sys_agents', "Judge AI model {$this->getModelId()} ask failed: " . $this->_sLastError, BX_LOG_ERR);
+            return [];
+        }
+    }
+
+    /**
+     * Evaluate questions against the state.
+     * @param $mixedState string|array text or structured data to evaluate
+     * @param $aQuestions ['question_id' => question built with noul()/choice()/score(), ...]
+     * @return array ['question_id' => answer, ...] where answer is
+     *   noul:   ['type' => 'noul', 'noul' => 0..1]
+     *   choice: ['type' => 'choice', 'choice' => key, 'confidence' => 0..1, 'probabilities' => [key => 0..1]]
+     *   score:  ['type' => 'score', 'score' => int, 'confidence' => 0..1, 'probabilities' => [level => 0..1], 'legend' => [level => text]]
+     * @throws Exception on transport / provider errors
+     */
+    abstract public function ask($mixedState, array $aQuestions): array;
+}
+
+/** @} */

+ 132 - 0
inc/classes/BxDolAiJudgeTypeSafe.php

@@ -0,0 +1,132 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * TypeSafe.ai judge (Jev / "System One" models) - https://docs.typesafe.ai
+ *
+ * Model row: type = typesafe, capabilities = judge, model = jev-latest | jev-preview | jev-1.13.0,
+ * key = API key, params JSON:
+ *   baseUri  - API root, default https://api.typesafe.ai/v1
+ *   timeout  - request timeout in seconds, default 15
+ *   retries  - how many times to retry on 429 / 529 / network error, default 2
+ *
+ * Limits (as of Jev 1.13): 64k tokens per request, 32k for state + longest question,
+ * choice up to 255 options, score 2..10 levels, text only.
+ */
+class BxDolAiJudgeTypeSafe extends BxDolAiJudge
+{
+    const BASE_URI = 'https://api.typesafe.ai/v1';
+    const ENDPOINT = '/systemone';
+
+    protected $_sBaseUri;
+    protected $_iTimeout;
+    protected $_iRetries;
+
+    public function __construct(array $aModel, array $aParams)
+    {
+        parent::__construct($aModel, $aParams);
+
+        $this->_sBaseUri = rtrim(!empty($aParams['baseUri']) ? $aParams['baseUri'] : self::BASE_URI, '/');
+        $this->_iTimeout = (int)($aParams['timeout'] ?? 15);
+        $this->_iRetries = (int)($aParams['retries'] ?? 2);
+    }
+
+    public function ask($mixedState, array $aQuestions): array
+    {
+        if (!$aQuestions)
+            throw new Exception('No questions');
+
+        foreach ($aQuestions as $sId => $aQuestion)
+            $this->_validateQuestion($sId, $aQuestion);
+
+        $aRequest = [
+            'state' => $mixedState,
+            'model' => $this->_aModel['model'],
+            'questions' => $aQuestions,
+        ];
+        $this->_aLastRequest = $aRequest;
+        $this->_aLastAnswers = [];
+
+        $aHeaders = [
+            'Authorization: Bearer ' . trim($this->_aModel['key']),
+            'Accept: application/json',
+        ];
+
+        $sUrl = $this->_sBaseUri . self::ENDPOINT;
+        $iAttempt = 0;
+        while (true) {
+            $sHttpCode = 0;
+            $sResponse = bx_file_get_contents($sUrl, $aRequest, 'post-json', $aHeaders, $sHttpCode, [], $this->_iTimeout);
+            $iHttpCode = (int)$sHttpCode;
+
+            if ($iHttpCode == 200 && $sResponse !== '' && $sResponse !== false)
+                break;
+
+            $bRetry = $iAttempt < $this->_iRetries && ($iHttpCode == 0 || $iHttpCode == 429 || $iHttpCode >= 500);
+            if (!$bRetry)
+                throw new Exception("TypeSafe API error: HTTP {$iHttpCode} " . $this->_errorFromResponse($sResponse));
+
+            $iAttempt++;
+            usleep(500000 * $iAttempt); // 0.5s, 1s, ...
+        }
+
+        $aResponse = json_decode($sResponse, true);
+        if (!is_array($aResponse) || !isset($aResponse['answers']) || !is_array($aResponse['answers']))
+            throw new Exception('TypeSafe API error: unexpected response ' . mb_substr((string)$sResponse, 0, 300));
+
+        $this->_aLastUsage = isset($aResponse['usage']) && is_array($aResponse['usage']) ? $aResponse['usage'] : [];
+        $this->_sLastModel = (string)($aResponse['model'] ?? '');
+        $this->_aLastAnswers = $aResponse['answers'];
+
+        return $aResponse['answers'];
+    }
+
+    protected function _validateQuestion($sId, $aQuestion)
+    {
+        if (!is_array($aQuestion) || empty($aQuestion['type']) || !isset($aQuestion['instructions']))
+            throw new Exception("Question '{$sId}' is malformed, use BxDolAiJudge::noul/choice/score");
+
+        switch ($aQuestion['type']) {
+            case self::Q_NOUL:
+                break;
+            case self::Q_CHOICE:
+                $iOptions = isset($aQuestion['criteria']) && is_array($aQuestion['criteria']) ? count($aQuestion['criteria']) : 0;
+                if ($iOptions < 2 || $iOptions > 255)
+                    throw new Exception("Question '{$sId}': choice needs 2..255 options");
+                break;
+            case self::Q_SCORE:
+                $iLevels = isset($aQuestion['criteria']) && is_array($aQuestion['criteria']) ? count($aQuestion['criteria']) : 0;
+                if ($iLevels < 2 || $iLevels > 10)
+                    throw new Exception("Question '{$sId}': score needs 2..10 levels");
+                break;
+            default:
+                throw new Exception("Question '{$sId}': unknown type {$aQuestion['type']}");
+        }
+    }
+
+    protected function _errorFromResponse($sResponse): string
+    {
+        if (!$sResponse)
+            return '(empty response)';
+
+        $a = json_decode($sResponse, true);
+        if (is_array($a)) {
+            if (!empty($a['error']))
+                return is_array($a['error']) ? json_encode($a['error']) : (string)$a['error'];
+            if (!empty($a['detail']))
+                return is_array($a['detail']) ? json_encode($a['detail']) : (string)$a['detail'];
+            if (!empty($a['message']))
+                return (string)$a['message'];
+        }
+
+        return mb_substr((string)$sResponse, 0, 300);
+    }
+}
+
+/** @} */

+ 863 - 0
inc/classes/BxDolAiSearchExtParser.php

@@ -0,0 +1,863 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Natural language query -> extended search params for ANY extended search object
+ * (sys_objects_search_extended, fields configured in Studio > Forms > Search fields).
+ *
+ * "python course under 50 in Berlin" for object bx_market ->
+ *   search_params = [
+ *     cat          => [type => select_multiple, value => [3],       operator => in],
+ *     price_single => [type => text_range,      value => ['', 50],  operator => between],
+ *     location     => [type => location,        value => [array => [0, 0, '', '', 'Berlin', ''], string => 'Berlin'], operator => locate],
+ *     title        => [type => text,            value => 'python',  operator => like],
+ *   ]
+ * which is exactly what BxDolSearchExtended::getResults(['search_params' => ...]) / the module's
+ * get_search_result_extended consume. Questions to the judge model are generated from the
+ * field list, so a field added or removed in Studio changes the parser automatically.
+ *
+ * Field search types handled: select / select_multiple / checkbox_set (pre-values list -> choice),
+ * checkbox / switcher (noul), text_range / slider (numbers from the query + choice min/max/exact),
+ * datepicker_range / datetime_range (calendar logic of BxDolAiSearchParser), location (city word +
+ * country choice), text (topic words -> like). Others are ignored.
+ */
+class BxDolAiSearchExtParser extends BxDolAiSearchParser
+{
+    const LIST_OPTIONS_MAX = 250;
+    const LIST_PROBABILITY_MIN = 0.3;
+    const BOOL_MIN = 0.6;
+    const RADIUS_DEFAULT_KM = 50;   // radius search when the query names a place but no distance
+    const RADIUS_MAX_KM = 500;
+    const CACHE_TTL_GEO_MISS = 600;  // a timeout or an empty answer is not a permanent "not found"
+
+    protected $_aFields = [];
+    protected $_aNumbers = [];
+    protected $_aTokens = [];
+    protected $_sQuery = '';
+    protected $_sObject = '';
+    protected $_iRadius = 0;        // km, when the query says "within 20 km"
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchExtParser();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    /**
+     * Fields of an extended search object that the parser can ask about, for the block (chips legend).
+     * @return array [name => [caption, kind]]
+     */
+    public function getFields(string $sObject): array
+    {
+        $aResult = [];
+        foreach ($this->_loadFields($sObject) as $sName => $aField)
+            $aResult[$sName] = ['caption' => $aField['caption'], 'kind' => $aField['kind']];
+
+        return $aResult;
+    }
+
+    /**
+     * @param $sObject extended search object name (e.g. bx_market)
+     * @param $sQuery natural language query
+     * @param $aOptions timezone, debug
+     * @return array
+     *   object, query, search_params (ready for get_results), chips [name => [field, caption, label, value]],
+     *   confidence [name => float], judge => null | [model, ms, usage], error / request / answers (debug)
+     */
+    public function parseObject(string $sObject, string $sQuery, array $aOptions = []): array
+    {
+        $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
+        $oTz = $this->_getTimezone($aOptions['timezone'] ?? '');
+        $oNow = new DateTime('now', $oTz);
+
+        $aResult = [
+            'object' => $sObject,
+            'query' => $sQuery,
+            'search_params' => [],
+            'filters' => [],
+            'keyword_field' => '',
+            'chips' => [],
+            'confidence' => [],
+            'judge' => null,
+        ];
+
+        $this->_sObject = $sObject;
+        $this->_aFields = $this->_loadFields($sObject);
+        if ($sQuery === '' || !$this->_oJudge || !$this->_aFields)
+            return $aResult;
+
+        $this->_sQuery = $sQuery;
+        $this->_iRadius = $this->_getRadius($sQuery);
+        $this->_aNumbers = $this->_getNumbers($sQuery);
+        $this->_aTokens = $this->_getTopicTokens($sQuery);
+
+        $aQuestions = $this->_getObjectQuestions();
+        if (!$aQuestions)
+            return $aResult;
+
+        $aState = ['today' => $oNow->format('Y-m-d l'), 'query' => $sQuery];
+
+        $fStart = microtime(true);
+        $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
+        if (!$aAnswers) {
+            if (!empty($aOptions['debug'])) {
+                $aResult['request'] = $this->_oJudge->getLastRequest();
+                $aResult['error'] = $this->_oJudge->getLastError() ?: 'empty answers';
+            }
+            return $aResult;
+        }
+
+        $this->_aAnswers = $aAnswers;
+        $aResult['judge'] = [
+            'model' => $this->_oJudge->getLastModel(),
+            'ms' => (int)round((microtime(true) - $fStart) * 1000),
+            'usage' => $this->_oJudge->getLastUsage(),
+        ];
+        if (!empty($aOptions['debug'])) {
+            $aResult['request'] = $this->_oJudge->getLastRequest();
+            $aResult['answers'] = $aAnswers;
+        }
+
+        foreach ($this->_aFields as $sName => $aField) {
+            $aParam = null;
+            switch ($aField['kind']) {
+                case 'list':
+                    $aParam = $this->_answerList($sName, $aField);
+                    break;
+                case 'bool':
+                    $aParam = $this->_answerBool($sName, $aField);
+                    break;
+                case 'number':
+                    continue 2; // handled once per group below
+                case 'date':
+                    $aParam = $this->_answerDate($sName, $aField, $oNow);
+                    break;
+                case 'location':
+                    $aParam = $this->_answerLocation($sName, $aField);
+                    break;
+                case 'text':
+                    $aParam = $this->_answerText($sName, $aField);
+                    break;
+            }
+
+            if (!$aParam)
+                continue;
+
+            $aResult['search_params'][$sName] = [
+                'type' => $aField['search_type'],
+                'value' => $aParam['value'],
+                'operator' => $aField['operator'],
+            ];
+
+            // same shape the search form would submit, for the page's own results block
+            $aFilters = $this->_getFilters($sName, $aField, $aParam);
+            foreach ($aFilters as $sKey => $mixedValue)
+                $aResult['filters'][$sKey] = $mixedValue;
+
+            $aResult['chips'][$sName] = [
+                'field' => $sName,
+                'caption' => $aField['caption'],
+                'label' => $aParam['label'],
+                'value' => $aParam['value'],
+                'filter_keys' => array_keys($aFilters),
+            ];
+            $aResult['confidence'][$sName] = $aParam['confidence'];
+
+            if ($aField['kind'] == 'text')
+                $aResult['keyword_field'] = $sName;
+        }
+
+        $this->_applyNumbers($aResult);
+
+        return $aResult;
+    }
+
+    /**
+     * Numeric fields with the same caption are one thing for the user (Market has two "Price" fields:
+     * one-time and recurring), so the query gets one constraint, one chip and a filter for every field
+     * of the group. An upper bound / "free" is applied to all of them (conditions are ANDed and the
+     * price a product does not use is 0); a lower bound or an exact value would then exclude everything,
+     * so it goes to the field the model picked.
+     */
+    protected function _applyNumbers(array &$aResult)
+    {
+        $aGroups = $this->_getNumberGroups();
+        if (!$aGroups)
+            return;
+
+        $sGroup = $this->_getNumberGroup($aGroups);
+        if ($sGroup === '' || empty($aGroups[$sGroup]))
+            return;
+
+        $aParam = $this->_answerNumber();
+        if (!$aParam)
+            return;
+
+        $aNames = $aGroups[$sGroup];
+        if (!in_array($aParam['op'], ['max', 'zero'], true)) {
+            $sPrimary = $this->_getNumberPrimaryField($aNames);
+            $aNames = [$sPrimary];
+        }
+
+        $aFilterKeys = [];
+        foreach ($aNames as $sName) {
+            $aField = $this->_aFields[$sName];
+
+            $aResult['search_params'][$sName] = [
+                'type' => $aField['search_type'],
+                'value' => $aParam['value'],
+                'operator' => $aField['operator'],
+            ];
+
+            foreach ($this->_getFilters($sName, $aField, $aParam) as $sKey => $mixedValue) {
+                $aResult['filters'][$sKey] = $mixedValue;
+                $aFilterKeys[] = $sKey;
+            }
+        }
+
+        $sChipId = reset($aNames);
+        $aResult['chips'][$sChipId] = [
+            'field' => $sChipId,
+            'caption' => $sGroup,
+            'label' => $aParam['label'],
+            'value' => $aParam['value'],
+            'filter_keys' => $aFilterKeys,
+        ];
+        $aResult['confidence'][$sChipId] = $aParam['confidence'];
+    }
+
+    /**
+     * Numeric fields grouped by caption: [caption => [field name, ...]]
+     */
+    protected function _getNumberGroups(): array
+    {
+        $aGroups = [];
+        foreach ($this->_aFields as $sName => $aField)
+            if ($aField['kind'] == 'number')
+                $aGroups[$aField['caption']][] = $sName;
+
+        return $aGroups;
+    }
+
+    /**
+     * Which group the query is about: the only one, or the one the model picked.
+     */
+    protected function _getNumberGroup(array $aGroups): string
+    {
+        if (count($aGroups) == 1)
+            return (string)key($aGroups);
+
+        list($sChoice, $fConfidence) = $this->_getChoice('num_field');
+        if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN || !isset($aGroups[$sChoice]))
+            return '';
+
+        return $sChoice;
+    }
+
+    /**
+     * Field of the group a lower / exact bound is applied to: the one the model picked, else the first.
+     */
+    protected function _getNumberPrimaryField(array $aNames): string
+    {
+        if (count($aNames) > 1 && isset($this->_aAnswers['num_which'])) {
+            list($sChoice, $fConfidence) = $this->_getChoice('num_which');
+            if ($sChoice !== '' && $sChoice != self::NONE && $fConfidence >= self::CONFIDENCE_MIN && in_array($sChoice, $aNames, true))
+                return $sChoice;
+        }
+
+        return reset($aNames);
+    }
+
+    /**
+     * One parsed field -> values as the search form would submit them ($_POST keys),
+     * so `get_results` (params.filters) builds exactly the same search as a manual form submit.
+     * @return array [input name => value]
+     */
+    protected function _getFilters(string $sName, array $aField, array $aParam): array
+    {
+        switch ($aField['kind']) {
+            case 'list':
+                // select posts one value, multiple selects post an array
+                return [$sName => $aField['multi'] ? (array)$aParam['value'] : (is_array($aParam['value']) ? reset($aParam['value']) : $aParam['value'])];
+
+            case 'bool':
+                return [$sName => 1];
+
+            case 'number':
+                // *_range inputs are rendered as name[] twice (from, to)
+                return [$sName => [(string)$aParam['value'][0], (string)$aParam['value'][1]]];
+
+            case 'date':
+                // the form checker converts date strings to timestamps itself
+                return [$sName => [$aParam['date_from'], $aParam['date_to']]];
+
+            case 'location':
+                // the main input holds the address string, the rest is read from {name}_{key} inputs
+                $aLocation = $aParam['value']['array'];
+                $aKeys = ['lat', 'lng', 'country', 'state', 'city', 'zip', 'street', 'street_number'];
+                $aFilters = [$sName => $aParam['value']['string']];
+                foreach ($aKeys as $iIndex => $sKey)
+                    $aFilters[$sName . '_' . $sKey] = isset($aLocation[$iIndex]) ? $aLocation[$iIndex] : '';
+                return $aFilters;
+
+            case 'text':
+            default:
+                return [$sName => $aParam['value']];
+        }
+    }
+
+    // fields ------------------------
+
+    /**
+     * Field name used as a caption when the language string is missing: price_recurring -> Price recurring.
+     */
+    protected function _getFieldCaption(string $sName): string
+    {
+        $s = str_replace('_', ' ', $sName);
+
+        return mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
+    }
+
+    /**
+     * Active fields of the extended search object, normalized:
+     * [name => [caption, kind (list|bool|number|date|location|text), multi, values, search_type, operator, type]]
+     */
+    protected function _loadFields(string $sObject): array
+    {
+        bx_import('BxDolForm'); // BX_DATA_LISTS_KEY_PREFIX, used while reading the fields' pre-values
+        $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject);
+        if (!$aObject || empty($aObject['fields']) || !is_array($aObject['fields']))
+            return [];
+
+        $aFields = [];
+        foreach ($aObject['fields'] as $aField) {
+            if (empty($aField['active']))
+                continue;
+
+            $sName = $aField['name'];
+            $sSearchType = $aField['search_type'];
+            $sOperator = $aField['search_operator'];
+            $sCaption = trim(strip_tags(_t($aField['caption'])));
+            if ($sCaption === '' || $sCaption == $aField['caption'])
+                $sCaption = $this->_getFieldCaption($sName);
+
+            $a = ['caption' => $sCaption, 'search_type' => $sSearchType, 'operator' => $sOperator, 'type' => $aField['type'], 'multi' => false, 'values' => []];
+
+            switch ($sSearchType) {
+                case 'select':
+                case 'select_multiple':
+                case 'checkbox_set':
+                    $aValues = [];
+                    if (!empty($aField['values']) && is_array($aField['values']))
+                        foreach ($aField['values'] as $mixedKey => $mixedValue) {
+                            if ($mixedKey === '' || $mixedKey === 0 || $mixedKey === '0' || is_array($mixedValue))
+                                continue;
+                            $aValues[(string)$mixedKey] = trim(strip_tags(_t((string)$mixedValue)));
+                        }
+                    if (count($aValues) < 1 || count($aValues) > self::LIST_OPTIONS_MAX)
+                        continue 2;
+                    $a['kind'] = 'list';
+                    $a['multi'] = $sSearchType != 'select';
+                    $a['values'] = $aValues;
+                    break;
+
+                case 'checkbox':
+                case 'switcher':
+                    $a['kind'] = 'bool';
+                    break;
+
+                case 'text_range':
+                case 'slider':
+                    $a['kind'] = 'number';
+                    break;
+
+                case 'datepicker_range':
+                case 'datetime_range':
+                    $a['kind'] = 'date';
+                    break;
+
+                case 'location':
+                case 'location_radius':
+                    $a['kind'] = 'location';
+                    break;
+
+                case 'text':
+                    if ($sOperator != 'like' || in_array($aField['type'], ['textarea']))
+                        continue 2;
+                    $a['kind'] = 'text';
+                    break;
+
+                default:
+                    continue 2;
+            }
+
+            $aFields[$sName] = $a;
+        }
+
+        return $aFields;
+    }
+
+    // questions ------------------------
+
+    protected function _getObjectQuestions(): array
+    {
+        $aQuestions = [];
+        $aDateFields = [];
+        $aTextFields = [];
+        $aNumberFields = [];
+        $bLocation = false;
+
+        foreach ($this->_aFields as $sName => $aField) {
+            switch ($aField['kind']) {
+                case 'list':
+                    $aCriteria = $aField['values'];
+                    $aCriteria[self::NONE] = "The query does not specify {$aField['caption']}";
+                    $aQuestions["f_{$sName}"] = BxDolAiJudge::choice(
+                        "Which {$aField['caption']} does the user ask for? The query may be in any language; match by meaning." . ($aField['multi'] ? ' Several may apply.' : ''),
+                        $aCriteria
+                    );
+                    break;
+
+                case 'bool':
+                    $aQuestions["f_{$sName}"] = BxDolAiJudge::noul("Does the user explicitly ask for items where \"{$aField['caption']}\" is true / enabled / yes?");
+                    break;
+
+                case 'number':
+                    $aNumberFields[$sName] = $aField['caption'];
+                    break;
+
+                case 'date':
+                    $aDateFields[$sName] = $aField['caption'];
+                    break;
+
+                case 'location':
+                    $bLocation = true;
+                    break;
+
+                case 'text':
+                    $aTextFields[$sName] = $aField['caption'];
+                    break;
+            }
+        }
+
+        if ($aNumberFields) {
+            $sCaptions = implode(' / ', array_unique(array_values($aNumberFields)));
+
+            // one constraint per query: which numeric thing it is about, and how
+            $aGroups = $this->_getNumberGroups();
+            if (count($aGroups) > 1) {
+                $aCriteria = [];
+                foreach ($aGroups as $sCaption => $aNames)
+                    $aCriteria[$sCaption] = $sCaption;
+                $aQuestions['num_field'] = BxDolAiJudge::choice(
+                    'If the query limits a number, which one does it mean?',
+                    $aCriteria + [self::NONE => 'The query does not limit any of these']
+                );
+            }
+
+            // fields sharing a caption are alternatives (one-time vs recurring price): for a lower or
+            // exact bound only one of them can be meant
+            foreach ($aGroups as $sCaption => $aNames) {
+                if (count($aNames) < 2)
+                    continue;
+                $aCriteria = [];
+                foreach ($aNames as $sName)
+                    $aCriteria[$sName] = $this->_getFieldCaption($sName);
+                $aQuestions['num_which'] = BxDolAiJudge::choice(
+                    "\"{$sCaption}\" is stored in several ways. Which one does the query mean?",
+                    $aCriteria + [self::NONE => 'Not specified']
+                );
+                break;
+            }
+
+            if ($this->_aNumbers) {
+                $sNumbers = implode(', ', $this->_aNumbers);
+                $aQuestions['num_op'] = BxDolAiJudge::choice(
+                    "The query contains the number(s) {$sNumbers}. How do they limit {$sCaptions}? Signs >, <, >=, <= and words under, over, from, up to, at least, max, min say how.",
+                    [
+                        'max' => 'at most / under / below / cheaper than the number (<, <=, under, up to, max)',
+                        'min' => 'at least / over / above / more than the number (>, >=, from, at least, min)',
+                        'exact' => 'exactly the number',
+                        'range' => 'between two numbers',
+                        self::NONE => 'The number is not a limit for it (a date, a quantity of something else, part of a name)',
+                    ]
+                );
+            }
+
+            $aQuestions['num_zero'] = BxDolAiJudge::noul("Does the user ask for items that are free — no charge, cost nothing, zero {$sCaptions}?");
+        }
+
+        if ($aDateFields) {
+            $aQuestions['time_mode'] = BxDolAiJudge::choice('How is the date written in the query?', [
+                'absolute' => 'A calendar date: 23/09, 23.09.2026, September 23, 23 sep',
+                'relative' => 'Relative to today: today, yesterday, tomorrow, this/last/next week, this weekend, this/last/next month',
+                self::NONE => 'No date in the query',
+            ]);
+            $aDays = [];
+            for ($i = 1; $i <= 31; $i++)
+                $aDays[(string)$i] = "Day {$i} of the month";
+            $aDays[self::NONE] = 'No day of month is written';
+            $aQuestions['day'] = BxDolAiJudge::choice('Which day of the month is written in the query? In numeric dates like 23/09 the day comes first.', $aDays);
+            $aQuestions['month'] = BxDolAiJudge::choice('Which month is written in the query? In numeric dates like 23/09 the month comes second.', [
+                '1' => 'January', '2' => 'February', '3' => 'March', '4' => 'April', '5' => 'May', '6' => 'June',
+                '7' => 'July', '8' => 'August', '9' => 'September', '10' => 'October', '11' => 'November', '12' => 'December',
+                self::NONE => 'No month is written',
+            ]);
+            $aQuestions['relative'] = BxDolAiJudge::choice('If the date is relative to today, which period is meant?', $this->_aRelative + [
+                self::NONE => 'The date is not relative, or there is no date',
+            ]);
+            if (count($aDateFields) > 1)
+                $aQuestions['date_field'] = BxDolAiJudge::choice('If the query mentions a date, which of these dates is meant?', $aDateFields + [
+                    self::NONE => 'No date in the query',
+                ]);
+        }
+
+        if ($bLocation && $this->_aTokens) {
+            // the word is geocoded afterwards, so any language and spelling works
+            $aQuestions['loc_city'] = BxDolAiJudge::choice(
+                'Which word names a place the items should be at — a city, town, area or country?',
+                $this->_tokensCriteria() + [self::NONE => 'No place in the query']
+            );
+        }
+
+        if ($aTextFields && $this->_aTokens) {
+            $aTopicCriteria = $this->_tokensCriteria(true);
+            if ($aTopicCriteria)
+                $aQuestions['topic'] = BxDolAiJudge::choice(
+                    'Which word names the topic, subject, product, title or name to search for in the text of the items? Words naming a kind of content, categories, places, dates, numbers, prices, time words, verbs like "find/show" and function words are NOT topic words.',
+                    $aTopicCriteria + [self::NONE => 'No topic word: the query only has categories, places, numbers or time']
+                );
+        }
+
+        return $aQuestions;
+    }
+
+    protected function _tokensCriteria(bool $bSkipFieldWords = false): array
+    {
+        $aSkip = $bSkipFieldWords ? $this->_getFieldWords() : [];
+
+        $aCriteria = [];
+        foreach ($this->_aTokens as $sToken)
+            if (!isset($aSkip[mb_strtolower($sToken)]))
+                $aCriteria[$sToken] = 'The word "' . $sToken . '"';
+
+        return $aCriteria;
+    }
+
+    /**
+     * Words of the field captions ("price", "category", "location"...): they name a filter,
+     * not the thing being searched for, so they must not become the keyword.
+     */
+    protected function _getFieldWords(): array
+    {
+        $aWords = [];
+        foreach ($this->_aFields as $aField) {
+            foreach (preg_split('/[^\p{L}\p{N}]+/u', $aField['caption'], -1, PREG_SPLIT_NO_EMPTY) as $sWord)
+                if (mb_strlen($sWord) > 2)
+                    $aWords[mb_strtolower($sWord)] = true;
+        }
+
+        return $aWords;
+    }
+
+    /**
+     * Numbers written in the query (integers / decimals), in order of appearance; parts of dates (23/09) are skipped.
+     */
+    protected function _getNumbers(string $sQuery): array
+    {
+        // drop date literals (23/09, 23.09.2026) but keep decimals like 50.5
+        $sClean = preg_replace_callback('/(?<![\d.])(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?(?![\d.])/', function ($m) {
+            $iA = (int)$m[1];
+            $iB = (int)$m[2];
+            $bDate = !empty($m[3]) || ($iA >= 1 && $iA <= 31 && $iB >= 1 && $iB <= 12) || ($iA >= 1 && $iA <= 12 && $iB >= 1 && $iB <= 31);
+            return $bDate ? ' ' : $m[0];
+        }, $sQuery);
+        if (!preg_match_all('/(?<![\p{L}\d])(\d+(?:[.,]\d+)?)(?![\d])/u', $sClean, $m))
+            return [];
+
+        $aNumbers = [];
+        foreach ($m[1] as $s) {
+            $fNumber = (float)str_replace(',', '.', $s);
+            if ($this->_iRadius > 0 && $fNumber == (float)$this->_iRadius)
+                continue; // it is the search radius, not a value of a field
+            $aNumbers[] = $fNumber;
+        }
+
+        return $aNumbers;
+    }
+
+    // answers ------------------------
+
+    /**
+     * @return array|null [value, label, confidence]
+     */
+    protected function _answerList(string $sName, array $aField)
+    {
+        list($sChoice, $fConfidence) = $this->_getChoice("f_{$sName}");
+        if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
+            return null;
+
+        $aKeys = [];
+        if ($aField['multi']) {
+            $aProbabilities = $this->_getProbabilities("f_{$sName}");
+            foreach ($aField['values'] as $sKey => $sTitle)
+                if ($sKey == $sChoice || ($aProbabilities[$sKey] ?? 0) >= self::LIST_PROBABILITY_MIN)
+                    $aKeys[] = $sKey;
+        }
+        if (!$aKeys)
+            $aKeys = [$sChoice];
+
+        $aTitles = [];
+        foreach ($aKeys as $sKey)
+            $aTitles[] = $aField['values'][$sKey] ?? $sKey;
+
+        return [
+            'value' => $aField['operator'] == 'in' || $aField['operator'] == 'and' ? $aKeys : $aKeys[0],
+            'label' => implode(', ', $aTitles),
+            'confidence' => $fConfidence,
+        ];
+    }
+
+    protected function _answerBool(string $sName, array $aField)
+    {
+        $a = $this->_aAnswers["f_{$sName}"] ?? null;
+        if (!is_array($a) || !isset($a['noul']) || (float)$a['noul'] < self::BOOL_MIN)
+            return null;
+
+        return ['value' => 1, 'label' => $aField['caption'], 'confidence' => (float)$a['noul']];
+    }
+
+    /**
+     * The numeric constraint of the query: ['op' => max|min|exact|range|zero, 'value' => [from, to], 'label', 'confidence']
+     */
+    protected function _answerNumber()
+    {
+        $aZero = $this->_aAnswers['num_zero'] ?? null;
+        $bZero = is_array($aZero) && isset($aZero['noul']) && (float)$aZero['noul'] >= self::BOOL_MIN;
+
+        if (!$this->_aNumbers) {
+            if (!$bZero)
+                return null;
+
+            return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']];
+        }
+
+        list($sChoice, $fConfidence) = $this->_getChoice('num_op');
+        if ($bZero && ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN))
+            return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']];
+
+        if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
+            return null;
+
+        $fNumber = $this->_aNumbers[0];
+        // "> 0" / ">= 0" is no constraint at all (UNA's between ignores an empty/zero lower bound)
+        if ($sChoice == 'min' && $fNumber <= 0)
+            return null;
+        $sFormatted = rtrim(rtrim(number_format($fNumber, 2, '.', ''), '0'), '.');
+        switch ($sChoice) {
+            case 'max':
+                return ['op' => 'max', 'value' => ['', $fNumber], 'label' => "≤ {$sFormatted}", 'confidence' => $fConfidence];
+            case 'min':
+                return ['op' => 'min', 'value' => [$fNumber, ''], 'label' => "≥ {$sFormatted}", 'confidence' => $fConfidence];
+            case 'exact':
+                return ['op' => 'exact', 'value' => [$fNumber, $fNumber], 'label' => "= {$sFormatted}", 'confidence' => $fConfidence];
+            case 'range':
+                if (count($this->_aNumbers) < 2)
+                    return null;
+                $fMin = min($this->_aNumbers[0], $this->_aNumbers[1]);
+                $fMax = max($this->_aNumbers[0], $this->_aNumbers[1]);
+                return ['op' => 'range', 'value' => [$fMin, $fMax], 'label' => rtrim(rtrim(number_format($fMin, 2, '.', ''), '0'), '.') . ' – ' . rtrim(rtrim(number_format($fMax, 2, '.', ''), '0'), '.'), 'confidence' => $fConfidence];
+        }
+
+        return null;
+    }
+
+    protected function _answerDate(string $sName, array $aField, DateTime $oNow)
+    {
+        // several date fields: the model picks which one the date is about
+        if (isset($this->_aAnswers['date_field'])) {
+            list($sChoice, $fConfidence) = $this->_getChoice('date_field');
+            if ($sChoice != $sName || $fConfidence < self::CONFIDENCE_MIN)
+                return null;
+        }
+
+        $aDate = $this->_getDate($this->_sQuery, $oNow, $sName);
+        if (!$aDate)
+            return null;
+
+        $oTz = $oNow->getTimezone();
+        $oFrom = new DateTime($aDate['date']['from'] . ' 00:00:00', $oTz);
+        $oTo = new DateTime($aDate['date']['to'] . ' 23:59:59', $oTz);
+
+        $bTimestamps = $aField['search_type'] == 'datetime_range' || $aField['type'] == 'datetime';
+        $mixedFrom = $bTimestamps ? $oFrom->getTimestamp() : $oFrom->format('Y-m-d');
+        $mixedTo = $bTimestamps ? $oTo->getTimestamp() : $oTo->format('Y-m-d');
+
+        $sLabel = $aDate['date']['from'] == $aDate['date']['to'] ? $aDate['date']['from'] : $aDate['date']['from'] . ' – ' . $aDate['date']['to'];
+        return [
+            'value' => [$mixedFrom, $mixedTo],
+            'date_from' => $bTimestamps ? $oFrom->format('Y-m-d H:i:s') : $oFrom->format('Y-m-d'),
+            'date_to' => $bTimestamps ? $oTo->format('Y-m-d H:i:s') : $oTo->format('Y-m-d'),
+            'label' => $sLabel,
+            'confidence' => $aDate['confidence'],
+        ];
+    }
+
+    protected function _answerLocation(string $sName, array $aField)
+    {
+        list($sPlace, $fConfidence) = $this->_getChoice('loc_city');
+        if ($sPlace === '' || $sPlace == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
+            return null;
+
+        $aGeo = $this->_geocode($sPlace);
+        if (!$aGeo)
+            return null;
+
+        $bRadius = $aField['search_type'] == 'location_radius';
+        $iRadius = $this->_iRadius > 0 ? $this->_iRadius : self::RADIUS_DEFAULT_KM;
+
+        $sLabel = $aGeo['label'] !== '' ? $aGeo['label'] : $sPlace;
+        if ($bRadius)
+            $sLabel .= ' +' . $iRadius . ' km';
+
+        // location_radius searches a box around the coordinates; plain location matches the stored
+        // country / city, so the geocoder's normalized names are used instead of the typed word
+        $aValue = [
+            (float)$aGeo['lat'],
+            (float)$aGeo['lng'],
+            $bRadius ? '' : $aGeo['country'],
+            '',
+            $bRadius ? '' : $aGeo['city'],
+            '',
+            '',
+            '',
+        ];
+        if ($bRadius)
+            $aValue[] = $iRadius;
+
+        return [
+            'value' => [
+                'array' => $aValue,
+                'string' => $sLabel,
+            ],
+            'label' => $sLabel,
+            'confidence' => $fConfidence,
+        ];
+    }
+
+    /**
+     * Place name (in any language) -> coordinates and normalized names, through the site's location
+     * field object (Nominatim by default). A hit is cached; a miss expires, so one timeout does not
+     * stay "not found" until the cache is cleared.
+     * @return array|null [lat, lng, country (2 letters), city, label]
+     */
+    protected function _geocode(string $sPlace)
+    {
+        $sPlace = trim($sPlace);
+        if ($sPlace === '')
+            return null;
+
+        $oDb = BxDolDb::getInstance();
+        $sCacheKey = 'sys_ai_search_geo_' . md5(mb_strtolower($sPlace));
+        $sMissKey = $sCacheKey . '_miss';
+
+        $mixedCached = $oDb->getCache($sCacheKey, '');
+        if (is_array($mixedCached) && isset($mixedCached['lat'], $mixedCached['lng']))
+            return $mixedCached;
+
+        // A failure used to be stored on the hit key as [] and getCache never expired it.
+        if (is_array($mixedCached))
+            $oDb->cleanCache($sCacheKey);
+
+        $mixedMiss = $oDb->getCache($sMissKey, '', self::CACHE_TTL_GEO_MISS);
+        if (is_array($mixedMiss) && (int)($mixedMiss['until'] ?? 0) > time())
+            return null;
+
+        $o = BxDolLocationField::getObjectInstance(getParam('sys_location_field_default'));
+        if (!$o || !method_exists($o, 'getLocation'))
+            $o = BxDolLocationField::getObjectInstance('sys_plain'); // Nominatim, no API key needed
+        if (!$o || !method_exists($o, 'getLocation'))
+            return null;
+
+        $aLocation = $o->getLocation(['q' => $sPlace]);
+        if (empty($aLocation) || !isset($aLocation['lat'], $aLocation['lon'])) {
+            $oDb->setCache($sMissKey, ['until' => time() + self::CACHE_TTL_GEO_MISS], self::CACHE_TTL_GEO_MISS);
+            return null;
+        }
+
+        $aAddress = !empty($aLocation['address']) && is_array($aLocation['address']) ? $aLocation['address'] : [];
+        $sCity = '';
+        foreach (['city', 'town', 'village', 'municipality', 'county', 'state'] as $sKey)
+            if (!empty($aAddress[$sKey])) {
+                $sCity = (string)$aAddress[$sKey];
+                break;
+            }
+
+        $aResult = [
+            'lat' => (float)$aLocation['lat'],
+            'lng' => (float)$aLocation['lon'],
+            'country' => !empty($aAddress['country_code']) ? mb_strtoupper((string)$aAddress['country_code']) : '',
+            'city' => $sCity,
+            'label' => trim(implode(', ', array_filter([$sCity, !empty($aAddress['country']) ? $aAddress['country'] : '']))),
+        ];
+
+        $oDb->setCache($sCacheKey, $aResult);
+        $oDb->cleanCache($sMissKey);
+
+        return $aResult;
+    }
+
+    /**
+     * "in 20 km" / "within 20 km" -> 20; the number is not a value of any field then.
+     * Unit words cover English and the same words in Cyrillic (code points, so the source stays ASCII).
+     */
+    protected function _getRadius(string $sQuery): int
+    {
+        if (!preg_match('/(\d{1,4})\s*(km|k\.?m\.?|км|mi|miles?|миль|мили)\b/iu', $sQuery, $m))
+            return 0;
+
+        $iRadius = (int)$m[1];
+
+        return $iRadius > 0 && $iRadius <= self::RADIUS_MAX_KM ? $iRadius : 0;
+    }
+
+    protected function _answerText(string $sName, array $aField)
+    {
+        // the keyword goes to the first text field only (conditions are ANDed by the engine)
+        foreach ($this->_aFields as $sOther => $aOther) {
+            if ($aOther['kind'] == 'text') {
+                if ($sOther != $sName)
+                    return null;
+                break;
+            }
+        }
+
+        list($sKeyword, $fConfidence) = $this->_getKeyword(array_keys($this->_tokensCriteria(true)));
+        if ($sKeyword === '')
+            return null;
+
+        return ['value' => $sKeyword, 'label' => $sKeyword, 'confidence' => $fConfidence];
+    }
+
+    /**
+     * No time_field question: the column decides the year. added and created are posting dates,
+     * date_start and date_end are when the item happens. Any other column keeps the current year.
+     */
+    protected function _getDate(string $sQuery, DateTime $oNow, string $sField)
+    {
+        $sChoice = in_array($sField, ['added', 'created', 'date_start', 'date_end'], true) ? $sField : 'date';
+        $this->_aAnswers['time_field'] = ['type' => 'choice', 'choice' => $sChoice, 'confidence' => 1.0, 'probabilities' => [$sChoice => 1.0]];
+
+        return parent::_getDate($sQuery, $oNow, '');
+    }
+}
+
+/** @} */

+ 465 - 0
inc/classes/BxDolAiSearchParser.php

@@ -0,0 +1,465 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Natural language search query -> structured search filter, using a judge AI model (see BxDolAiJudge).
+ *
+ * "all events starting on 23/09" ->
+ *   section = bx_events, keyword = '', date = [field => starts, from => 2026-09-23, to => 2026-09-23]
+ *
+ * The judge only answers closed questions (which section, which time field, day, month, relative anchor,
+ * which words are the topic); dates are assembled in code. When no judge model is configured the query
+ * is returned as a plain keyword, so callers work exactly as before.
+ */
+class BxDolAiSearchParser extends BxDolFactory
+{
+    const CONFIDENCE_MIN = 0.35;
+    const TOPIC_PROBABILITY_MIN = 0.2;
+    const TOPIC_TOKENS_MAX = 60;
+
+    const NONE = 'none';
+
+    protected $_oJudge;
+    protected $_aAnswers = [];
+
+    /**
+     * Section hints, module name => note from sys_modules. Null until the first load.
+     * The section title (site language) is always appended.
+     */
+    protected $_aSectionHints = null;
+
+    protected $_aRelative = [
+        'today' => 'Today',
+        'yesterday' => 'Yesterday',
+        'tomorrow' => 'Tomorrow',
+        'weekend' => 'This weekend, Saturday or Sunday',
+        'this_week' => 'This week',
+        'last_week' => 'Last week, the previous week',
+        'next_week' => 'Next week',
+        'this_month' => 'This month',
+        'last_month' => 'Last month, the previous month',
+        'next_month' => 'Next month',
+    ];
+
+    protected function __construct()
+    {
+        parent::__construct();
+        $this->_oJudge = BxDolAiJudge::getInstance();
+    }
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchParser();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function isAvailable(): bool
+    {
+        return (bool)$this->_oJudge;
+    }
+
+    /**
+     * @param $sQuery natural language query
+     * @param $aOptions
+     *   sections  - [name => title] search sections the user may search in (required for section detection)
+     *   timezone  - IANA timezone of the user, used for "today"/"tomorrow" and day boundaries
+     *   debug     - include raw judge answers in the result
+     * @return array
+     *   query, keyword, section, section_title,
+     *   date => null | [field (starts|ends|created), mode (absolute|relative), anchor, from (Y-m-d), to (Y-m-d)],
+     *   confidence => [section, date, keyword], judge => null | [model, ms, usage]
+     */
+    public function parse(string $sQuery, array $aOptions = []): array
+    {
+        $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
+        $aSections = !empty($aOptions['sections']) && is_array($aOptions['sections']) ? $aOptions['sections'] : [];
+        $oTz = $this->_getTimezone($aOptions['timezone'] ?? '');
+
+        $aResult = [
+            'query' => $sQuery,
+            'keyword' => $sQuery,
+            'section' => '',
+            'section_title' => '',
+            'date' => null,
+            'confidence' => ['section' => 0, 'date' => 0, 'keyword' => 0],
+            'judge' => null,
+        ];
+
+        if ($sQuery === '' || !$this->_oJudge)
+            return $aResult;
+
+        $aTokens = $this->_getTopicTokens($sQuery);
+        $aQuestions = $this->_getQuestions($aSections, $aTokens);
+
+        $oNow = new DateTime('now', $oTz);
+        $aState = [
+            'today' => $oNow->format('Y-m-d l'),
+            'query' => $sQuery,
+        ];
+
+        $fStart = microtime(true);
+        $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
+        if (!$aAnswers) {
+            if (!empty($aOptions['debug'])) {
+                $aResult['request'] = $this->_oJudge->getLastRequest();
+                $aResult['error'] = $this->_oJudge->getLastError() ?: 'empty answers';
+            }
+            return $aResult;
+        }
+
+        $this->_aAnswers = $aAnswers;
+        $aResult['judge'] = [
+            'model' => $this->_oJudge->getLastModel(),
+            'ms' => (int)round((microtime(true) - $fStart) * 1000),
+            'usage' => $this->_oJudge->getLastUsage(),
+        ];
+        if (!empty($aOptions['debug'])) {
+            $aResult['request'] = $this->_oJudge->getLastRequest();
+            $aResult['answers'] = $aAnswers;
+        }
+
+        // section
+        list($sSection, $fConfidence) = $this->_getChoice('section');
+        if ($sSection && $sSection != 'any' && isset($aSections[$sSection]) && $fConfidence >= self::CONFIDENCE_MIN) {
+            $aResult['section'] = $sSection;
+            $aResult['section_title'] = $aSections[$sSection];
+        }
+        $aResult['confidence']['section'] = $fConfidence;
+
+        // date
+        $aDate = $this->_getDate($sQuery, $oNow, $aResult['section']);
+        if ($aDate) {
+            $aResult['date'] = $aDate['date'];
+            $aResult['confidence']['date'] = $aDate['confidence'];
+
+            // a bare date with no section makes sense for events only
+            if (!$aResult['section'] && in_array($aDate['date']['field'], ['starts', 'ends']) && isset($aSections['bx_events'])) {
+                $aResult['section'] = 'bx_events';
+                $aResult['section_title'] = $aSections['bx_events'];
+            }
+        }
+
+        // keyword
+        list($sKeyword, $fConfidence) = $this->_getKeyword($aTokens);
+        $aResult['keyword'] = $sKeyword;
+        $aResult['confidence']['keyword'] = $fConfidence;
+
+        return $aResult;
+    }
+
+    // questions ------------------------
+
+    /**
+     * Keyword hints published by each module as its note (sys_modules.note).
+     */
+    protected function _getSectionHints(): array
+    {
+        if (is_array($this->_aSectionHints))
+            return $this->_aSectionHints;
+
+        $this->_aSectionHints = [];
+
+        $aModules = BxDolModuleQuery::getInstance()->getModules();
+        if (!is_array($aModules))
+            return $this->_aSectionHints;
+
+        foreach ($aModules as $aModule)
+            if (!empty($aModule['name']) && !empty($aModule['note']))
+                $this->_aSectionHints[$aModule['name']] = $aModule['note'];
+
+        return $this->_aSectionHints;
+    }
+
+    protected function _getQuestions(array $aSections, array $aTokens): array
+    {
+        $aQuestions = [];
+
+        if ($aSections) {
+            $aCriteria = [];
+            $aHints = $this->_getSectionHints();
+            foreach ($aSections as $sName => $sTitle)
+                $aCriteria[$sName] = (!empty($aHints[$sName]) ? $aHints[$sName] . ' - ' : '') . $sTitle;
+            $aCriteria['any'] = 'Not specified, everything, or a kind of content not listed';
+
+            $aQuestions['section'] = BxDolAiJudge::choice('Which kind of content does the user want to find? The query may be in any language.', $aCriteria);
+        }
+
+        $aQuestions['time_field'] = BxDolAiJudge::choice('If the query mentions a date or time, which moment of the items is meant?', [
+            'starts' => 'When the item starts, happens, takes place, is held (e.g. events on a date)',
+            'ends' => 'When the item ends, its deadline or closing date',
+            'created' => 'When the item was posted, published, created, added',
+            self::NONE => 'The query has no date or time reference',
+        ]);
+
+        $aQuestions['time_mode'] = BxDolAiJudge::choice('How is the date written in the query?', [
+            'absolute' => 'A calendar date: 23/09, 23.09.2026, September 23, 23 sep',
+            'relative' => 'Relative to today: today, tomorrow, this week, next week, this weekend, this month',
+            self::NONE => 'No date in the query',
+        ]);
+
+        $aDays = [];
+        for ($i = 1; $i <= 31; $i++)
+            $aDays[(string)$i] = "Day {$i} of the month";
+        $aDays[self::NONE] = 'No day of month is written';
+        $aQuestions['day'] = BxDolAiJudge::choice('Which day of the month is written in the query? In numeric dates like 23/09 or 23.09 the day comes first.', $aDays);
+
+        $aQuestions['month'] = BxDolAiJudge::choice('Which month is written in the query? In numeric dates like 23/09 or 23.09 the month comes second.', [
+            '1' => 'January', '2' => 'February', '3' => 'March', '4' => 'April', '5' => 'May', '6' => 'June',
+            '7' => 'July', '8' => 'August', '9' => 'September', '10' => 'October', '11' => 'November', '12' => 'December',
+            self::NONE => 'No month is written',
+        ]);
+
+        $aQuestions['relative'] = BxDolAiJudge::choice('If the date is relative to today, which period is meant?', $this->_aRelative + [
+            self::NONE => 'The date is not relative, or there is no date',
+        ]);
+
+        if ($aTokens) {
+            $aCriteria = [];
+            foreach ($aTokens as $sToken)
+                $aCriteria[$sToken] = 'The word "' . $sToken . '"';
+            $aCriteria[self::NONE] = 'No topic word: the query only names a kind of content and/or a time';
+
+            $aQuestions['topic'] = BxDolAiJudge::choice('Which word names the topic, subject, name or thing to search for? Words that only name a kind of content (events, posts, people, groups, courses...), dates, time words (today, tomorrow, week), verbs like "find/show/start" and function words are NOT topic words.', $aCriteria);
+        }
+
+        return $aQuestions;
+    }
+
+    /**
+     * Candidate words for the keyword: letters only, 2+ chars, unique, original order.
+     */
+    protected function _getTopicTokens(string $sQuery): array
+    {
+        $aWords = preg_split('/[^\p{L}\p{N}]+/u', $sQuery, -1, PREG_SPLIT_NO_EMPTY);
+
+        $aTokens = [];
+        foreach ($aWords as $sWord) {
+            if (mb_strlen($sWord) < 2 || preg_match('/\p{N}/u', $sWord))
+                continue;
+            $sKey = mb_strtolower($sWord);
+            if (isset($aTokens[$sKey]) || $sKey == self::NONE || $sKey == 'any')
+                continue;
+            $aTokens[$sKey] = $sWord;
+            if (count($aTokens) >= self::TOPIC_TOKENS_MAX)
+                break;
+        }
+
+        return array_values($aTokens);
+    }
+
+    // answers ------------------------
+
+    /**
+     * @return array [choice, confidence]
+     */
+    protected function _getChoice(string $sId): array
+    {
+        $a = $this->_aAnswers[$sId] ?? null;
+        if (!is_array($a) || !isset($a['choice']))
+            return ['', 0.0];
+
+        return [(string)$a['choice'], (float)($a['confidence'] ?? 0)];
+    }
+
+    protected function _getProbabilities(string $sId): array
+    {
+        $a = $this->_aAnswers[$sId] ?? null;
+        return is_array($a) && isset($a['probabilities']) && is_array($a['probabilities']) ? $a['probabilities'] : [];
+    }
+
+    /**
+     * @return array|null ['date' => [field, mode, anchor, from, to], 'confidence' => float]
+     */
+    protected function _getDate(string $sQuery, DateTime $oNow, string $sSection)
+    {
+        list($sField, $fFieldConfidence) = $this->_getChoice('time_field');
+        list($sMode, $fModeConfidence) = $this->_getChoice('time_mode');
+
+        // exact numeric date in the query beats the model's reading of day/month
+        $aLiteral = $this->_getLiteralDate($sQuery);
+
+        if (!$aLiteral && ($sMode == self::NONE || $sMode == '' || $fModeConfidence < self::CONFIDENCE_MIN))
+            return null;
+
+        // starts/ends/created are the main parser's answers. added/created/date_start/date_end are extended-search columns.
+        // 'date' is any other extended-search column: a missing year stays the current one.
+        if (!in_array($sField, ['starts', 'ends', 'created', 'added', 'date_start', 'date_end', 'date']))
+            $sField = $sSection && $sSection != 'bx_events' ? 'created' : 'starts';
+
+        $iYear = (int)$oNow->format('Y');
+        $sMode = $aLiteral ? 'absolute' : $sMode;
+
+        if ($sMode == 'relative') {
+            list($sAnchor, $fAnchorConfidence) = $this->_getChoice('relative');
+            if (!isset($this->_aRelative[$sAnchor]) || $fAnchorConfidence < self::CONFIDENCE_MIN)
+                return null;
+
+            list($oFrom, $oTo) = $this->_getRelativeRange($sAnchor, $oNow);
+            return [
+                'date' => ['field' => $sField, 'mode' => 'relative', 'anchor' => $sAnchor, 'from' => $oFrom->format('Y-m-d'), 'to' => $oTo->format('Y-m-d')],
+                'confidence' => min($fFieldConfidence, $fModeConfidence, $fAnchorConfidence),
+            ];
+        }
+
+        // absolute
+        $fConfidence = min($fFieldConfidence, $fModeConfidence);
+        if ($aLiteral) {
+            list($iDay, $iMonth, $iLiteralYear) = $aLiteral;
+            $fConfidence = max($fConfidence, $fFieldConfidence);
+        }
+        else {
+            list($sDay, $fDayConfidence) = $this->_getChoice('day');
+            list($sMonth, $fMonthConfidence) = $this->_getChoice('month');
+            if ($sDay == self::NONE || $sMonth == self::NONE || !is_numeric($sDay) || !is_numeric($sMonth))
+                return null;
+
+            $iDay = (int)$sDay;
+            $iMonth = (int)$sMonth;
+            $iLiteralYear = 0;
+            $fConfidence = min($fConfidence, $fDayConfidence, $fMonthConfidence);
+        }
+
+        if ($fConfidence < self::CONFIDENCE_MIN)
+            return null;
+
+        if ($iLiteralYear)
+            $iYear = $iLiteralYear;
+        else {
+            // no year stated: nearest sensible one
+            if (checkdate($iMonth, $iDay, $iYear)) {
+                $oCandidate = (clone $oNow)->setDate($iYear, $iMonth, $iDay)->setTime(0, 0, 0);
+                $iDiffDays = (int)$oNow->diff($oCandidate)->format('%r%a');
+                if (in_array($sField, ['created', 'added']) && $iDiffDays > 1)
+                    $iYear--;
+                elseif (in_array($sField, ['starts', 'ends', 'date_start', 'date_end']) && $iDiffDays < -30)
+                    $iYear++;
+            }
+        }
+
+        if (!checkdate($iMonth, $iDay, $iYear))
+            return null;
+
+        $sDate = sprintf('%04d-%02d-%02d', $iYear, $iMonth, $iDay);
+        return [
+            'date' => ['field' => $sField, 'mode' => 'absolute', 'anchor' => '', 'from' => $sDate, 'to' => $sDate],
+            'confidence' => $fConfidence,
+        ];
+    }
+
+    /**
+     * 23/09, 23.09, 23-09, 23/09/2026, 23.09.26 -> [day, month, year|0]; null when absent or impossible.
+     */
+    protected function _getLiteralDate(string $sQuery)
+    {
+        if (!preg_match('/(?<![\d\/.\-])(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?(?![\d\/.\-])/', $sQuery, $m))
+            return null;
+
+        $iDay = (int)$m[1];
+        $iMonth = (int)$m[2];
+        $iYear = isset($m[3]) ? (int)$m[3] : 0;
+        if ($iYear && $iYear < 100)
+            $iYear += 2000;
+
+        if ($iMonth > 12 && $iDay <= 12) // 09/23 written month-first
+            list($iDay, $iMonth) = [$iMonth, $iDay];
+
+        if ($iDay < 1 || $iDay > 31 || $iMonth < 1 || $iMonth > 12)
+            return null;
+
+        return [$iDay, $iMonth, $iYear];
+    }
+
+    /**
+     * @return array [DateTime from, DateTime to]
+     */
+    protected function _getRelativeRange(string $sAnchor, DateTime $oNow): array
+    {
+        $oFrom = (clone $oNow)->setTime(0, 0, 0);
+        $oTo = clone $oFrom;
+        $iDow = (int)$oNow->format('N'); // 1 = Monday .. 7 = Sunday
+
+        switch ($sAnchor) {
+            case 'yesterday':
+                $oFrom->modify('-1 day');
+                $oTo = clone $oFrom;
+                break;
+            case 'tomorrow':
+                $oFrom->modify('+1 day');
+                $oTo = clone $oFrom;
+                break;
+            case 'last_week':
+                $oFrom->modify('-' . ($iDow + 6) . ' days');
+                $oTo = (clone $oFrom)->modify('+6 days');
+                break;
+            case 'last_month':
+                $oFrom->modify('first day of last month');
+                $oTo = (clone $oFrom)->modify('last day of this month');
+                break;
+            case 'next_month':
+                $oFrom->modify('first day of next month');
+                $oTo = (clone $oFrom)->modify('last day of this month');
+                break;
+            case 'weekend':
+                $oFrom->modify($iDow == 7 ? '-1 day' : '+' . (6 - $iDow) . ' days');
+                $oTo = (clone $oFrom)->modify('+1 day');
+                break;
+            case 'this_week':
+                $oFrom->modify('-' . ($iDow - 1) . ' days');
+                $oTo = (clone $oFrom)->modify('+6 days');
+                break;
+            case 'next_week':
+                $oFrom->modify('+' . (8 - $iDow) . ' days');
+                $oTo = (clone $oFrom)->modify('+6 days');
+                break;
+            case 'this_month':
+                $oFrom->modify('first day of this month');
+                $oTo = (clone $oFrom)->modify('last day of this month');
+                break;
+            case 'today':
+            default:
+                break;
+        }
+
+        return [$oFrom, $oTo];
+    }
+
+    /**
+     * @return array [keyword, confidence]
+     */
+    protected function _getKeyword(array $aTokens): array
+    {
+        if (!$aTokens || !isset($this->_aAnswers['topic']))
+            return ['', 0.0];
+
+        list($sChoice, $fConfidence) = $this->_getChoice('topic');
+        $aProbabilities = $this->_getProbabilities('topic');
+
+        if ($sChoice == self::NONE || ($aProbabilities[self::NONE] ?? 0) > 0.5)
+            return ['', $fConfidence];
+
+        $aKeyword = [];
+        foreach ($aTokens as $sToken)
+            if ($sToken == $sChoice || ($aProbabilities[$sToken] ?? 0) >= self::TOPIC_PROBABILITY_MIN)
+                $aKeyword[] = $sToken;
+
+        return [implode(' ', $aKeyword), $fConfidence];
+    }
+
+    protected function _getTimezone(string $sTimezone): DateTimeZone
+    {
+        if ($sTimezone && in_array($sTimezone, timezone_identifiers_list()))
+            return new DateTimeZone($sTimezone);
+
+        return new DateTimeZone(date_default_timezone_get() ?: 'UTC');
+    }
+}
+
+/** @} */

+ 283 - 0
inc/classes/BxDolAiSearchRerank.php

@@ -0,0 +1,283 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Semantic ordering of search results: the SQL picks candidates, a judge model (BxDolAiJudge)
+ * decides which of them actually answer the query and in which order.
+ *
+ * Candidates are collected with two queries (the "hybrid"), both with the same structured filters
+ * (category, price, date...) and the same access rights:
+ *   1. with the keyword    - exact text hits, what the plain search would return;
+ *   2. without the keyword - the newest items of the filtered set.
+ * The second query is what makes "course for beginners" find "Python from scratch": such an item has
+ * none of the query words, so a keyword search can never return it, but the model recognises it once
+ * it is among the candidates.
+ *
+ * Judgements are cached per (item, query) so repeated and paginated searches cost nothing, and the
+ * ordered list of ids is cached per (viewer + query + filters) so pages stay consistent. The
+ * candidate set depends on who is searching, so that list is not shared between members.
+ *
+ * Limits: a judge request carries at most CANDIDATES_MAX items (the model's state limit), so this is
+ * re-ranking of a window, not a search over the whole table. For a big table a recall layer
+ * (embeddings vector store) would be needed in front of it.
+ */
+class BxDolAiSearchRerank extends BxDolFactory
+{
+    const CANDIDATES_MAX = 120;     // items sent to the model in one request
+    const KEYWORD_SHARE = 0.5;      // how much of the window the keyword query may take
+    const TEXT_MAX = 220;           // characters of the item text put into the state
+    const SCORE_MIN = 0.45;         // below this an item is dropped from the results
+    const CACHE_TTL_ORDER = 600;    // ordered ids per viewer+query+filters, seconds
+    const CACHE_TTL_ITEM = 86400;   // judgement per item+query, seconds
+    const BUDGET_ITEMS_REQUEST = 200; // hard cap of judged items per request
+
+    protected $_oJudge;
+    protected $_oCache;
+
+    protected function __construct()
+    {
+        parent::__construct();
+        $this->_oJudge = BxDolAiJudge::getInstance();
+        $this->_oCache = BxDolDb::getInstance()->getDbCacheObject();
+    }
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchRerank();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function isAvailable(): bool
+    {
+        return (bool)$this->_oJudge;
+    }
+
+    /**
+     * Ordered ids of an extended search object for a natural language query.
+     *
+     * @param $sObject extended search object (sys_objects_search_extended)
+     * @param $sQuery the user's query as typed
+     * @param $aSearchParams structured params (as built by BxDolAiSearchExtParser), keyword field included
+     * @param $aOptions
+     *   keyword_field - name of the text field holding the keyword, it is dropped in the second query
+     *   debug         - return scores per item
+     * @return array ['ids' => [...], 'scores' => [id => 0..1], 'judged' => N, 'cached' => N, 'ms' => N]
+     *         or [] when the model is not available / nothing to rank
+     */
+    public function rank(string $sObject, string $sQuery, array $aSearchParams, array $aOptions = []): array
+    {
+        $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
+        if (!$this->_oJudge || $sQuery === '')
+            return [];
+
+        bx_import('BxDolForm'); // BX_DATA_LISTS_KEY_PREFIX, used while reading the fields' pre-values
+        $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject);
+        if (!$aObject || empty($aObject['object_content_info']))
+            return [];
+
+        // Content filter and any search_ids hook run as the current profile, so the same
+        // query and filters can produce a different set of ids for another member.
+        $sCacheKeyOrder = $this->_getCacheKey('order', $sObject . '|' . $sQuery . '|' . md5(serialize($aSearchParams)) . '|' . $this->_getViewerKey());
+        $aOrder = $this->_oCache->getData($sCacheKeyOrder, self::CACHE_TTL_ORDER);
+        if (is_array($aOrder) && !empty($aOrder['ids']))
+            return $aOrder;
+
+        $fStart = microtime(true);
+
+        $aIds = $this->_getCandidates($aObject, $aSearchParams, $aOptions['keyword_field'] ?? '');
+        if (!$aIds)
+            return [];
+
+        $aItems = $this->_getItems($aObject, $aIds);
+        if (!$aItems)
+            return [];
+
+        list($aScores, $iJudged, $iCached) = $this->_getScores($sObject, $sQuery, $aItems);
+        if (!$aScores)
+            return [];
+
+        arsort($aScores);
+
+        $aResultIds = [];
+        foreach ($aScores as $iId => $fScore)
+            if ($fScore >= self::SCORE_MIN)
+                $aResultIds[] = (int)$iId;
+
+        $aResult = [
+            'ids' => $aResultIds,
+            'judged' => $iJudged,
+            'cached' => $iCached,
+            'ms' => (int)round((microtime(true) - $fStart) * 1000),
+        ];
+        if (!empty($aOptions['debug']))
+            $aResult['scores'] = $aScores;
+
+        $this->_oCache->setData($sCacheKeyOrder, $aResult, self::CACHE_TTL_ORDER);
+
+        return $aResult;
+    }
+
+    /**
+     * Hybrid candidates: the keyword query plus the same query without the keyword.
+     * Both run through the module's own search, so privacy and status conditions are applied as usual.
+     */
+    protected function _getCandidates(array $aObject, array $aSearchParams, string $sKeywordField): array
+    {
+        $oContentInfo = BxDolContentInfo::getObjectInstance($aObject['object_content_info']);
+        if (!$oContentInfo)
+            return [];
+
+        $iKeywordMax = (int)round(self::CANDIDATES_MAX * self::KEYWORD_SHARE);
+
+        $aIds = [];
+        $bHasKeyword = $sKeywordField !== '' && isset($aSearchParams[$sKeywordField]);
+
+        // 1. exact text hits
+        if ($bHasKeyword) {
+            $aKeywordIds = $oContentInfo->getSearchResultExtended($aSearchParams, 0, $iKeywordMax);
+            if (is_array($aKeywordIds))
+                $aIds = $aKeywordIds;
+        }
+
+        // 2. the same set without the keyword - items no word of the query matches.
+        //    With no structured filters left this is simply the newest items of the module
+        //    (filter mode, otherwise an empty param set returns nothing).
+        $aParamsWide = $aSearchParams;
+        if ($bHasKeyword)
+            unset($aParamsWide[$sKeywordField]);
+
+        $aWideIds = $oContentInfo->getSearchResultExtended($aParamsWide, 0, self::CANDIDATES_MAX, empty($aParamsWide));
+        if (is_array($aWideIds))
+            $aIds = array_merge($aIds, $aWideIds);
+
+        $aIds = array_values(array_unique(array_map('intval', $aIds)));
+
+        return array_slice($aIds, 0, self::CANDIDATES_MAX);
+    }
+
+    /**
+     * [id => "title. text"] for the candidates, in one query when the module's table is known.
+     */
+    protected function _getItems(array $aObject, array $aIds): array
+    {
+        $aItems = [];
+
+        $oModule = !empty($aObject['module']) ? BxDolModule::getInstance($aObject['module']) : null;
+        $CNF = $oModule && !empty($oModule->_oConfig->CNF) ? $oModule->_oConfig->CNF : [];
+
+        if (!empty($CNF['TABLE_ENTRIES']) && !empty($CNF['FIELD_ID']) && !empty($CNF['FIELD_TITLE'])) {
+            $oDb = BxDolDb::getInstance();
+            $sFieldText = !empty($CNF['FIELD_TEXT']) ? $CNF['FIELD_TEXT'] : '';
+
+            $sFields = "`{$CNF['FIELD_ID']}` AS `id`, `{$CNF['FIELD_TITLE']}` AS `title`" . ($sFieldText ? ", `{$sFieldText}` AS `text`" : '');
+            $aRows = $oDb->getAll("SELECT {$sFields} FROM `{$CNF['TABLE_ENTRIES']}` WHERE `{$CNF['FIELD_ID']}` IN (" . $oDb->implode_escape($aIds) . ")");
+
+            foreach ($aRows as $aRow)
+                $aItems[(int)$aRow['id']] = $this->_getItemText($aRow['title'] ?? '', $aRow['text'] ?? '');
+        }
+        else {
+            // no CNF: fall back to the content info services
+            $oContentInfo = BxDolContentInfo::getObjectInstance($aObject['object_content_info']);
+            foreach ($aIds as $iId)
+                $aItems[$iId] = $this->_getItemText((string)$oContentInfo->getContentTitle($iId), (string)$oContentInfo->getContentText($iId));
+        }
+
+        // keep the candidate order, drop empty ones
+        $aOrdered = [];
+        foreach ($aIds as $iId)
+            if (!empty($aItems[$iId]))
+                $aOrdered[$iId] = $aItems[$iId];
+
+        return $aOrdered;
+    }
+
+    protected function _getItemText(string $sTitle, string $sText): string
+    {
+        $sTitle = trim(strip_tags($sTitle));
+        $sText = trim(preg_replace('/\s+/u', ' ', strip_tags($sText)));
+        if (mb_strlen($sText) > self::TEXT_MAX)
+            $sText = mb_substr($sText, 0, self::TEXT_MAX) . '…';
+
+        return trim($sTitle . ($sText !== '' ? '. ' . $sText : ''));
+    }
+
+    /**
+     * Judgement per item: from cache when this item was already judged for this query,
+     * the rest in one request to the model.
+     * @return array [[id => score], judged, cached]
+     */
+    protected function _getScores(string $sObject, string $sQuery, array $aItems): array
+    {
+        $sQueryKey = md5(mb_strtolower($sQuery));
+
+        $aScores = [];
+        $aAsk = [];
+        foreach ($aItems as $iId => $sText) {
+            $mixedScore = $this->_oCache->getData($this->_getCacheKey('item', $sObject . '_' . $iId . '_' . $sQueryKey), self::CACHE_TTL_ITEM);
+            if ($mixedScore !== null && $mixedScore !== false)
+                $aScores[$iId] = (float)$mixedScore;
+            else
+                $aAsk[$iId] = $sText;
+        }
+
+        $iCached = count($aScores);
+        if (!$aAsk)
+            return [$aScores, 0, $iCached];
+
+        if (count($aAsk) > self::BUDGET_ITEMS_REQUEST)
+            $aAsk = array_slice($aAsk, 0, self::BUDGET_ITEMS_REQUEST, true);
+
+        $aState = [
+            'request' => $sQuery,
+            'items' => [],
+        ];
+        $aQuestions = [];
+        foreach ($aAsk as $iId => $sText) {
+            $aState['items']['i' . $iId] = $sText;
+            $aQuestions['i' . $iId] = BxDolAiJudge::noul(
+                'Does item i' . $iId . ' satisfy the request? Judge by meaning, not by matching words: an item can answer the request with completely different wording.'
+            );
+        }
+
+        $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
+        if (!$aAnswers)
+            return [$aScores, 0, $iCached];
+
+        foreach ($aAsk as $iId => $sText) {
+            $a = $aAnswers['i' . $iId] ?? null;
+            if (!is_array($a) || !isset($a['noul']))
+                continue;
+
+            $fScore = (float)$a['noul'];
+            $aScores[$iId] = $fScore;
+            $this->_oCache->setData($this->_getCacheKey('item', $sObject . '_' . $iId . '_' . $sQueryKey), $fScore, self::CACHE_TTL_ITEM);
+        }
+
+        return [$aScores, count($aAsk), $iCached];
+    }
+
+    /**
+     * Profile the candidates were selected for, plus the content-filter mask applied to that profile.
+     * Guests share profile 0 and the unauthenticated mask.
+     */
+    protected function _getViewerKey(): string
+    {
+        $iProfileId = (int)bx_get_logged_profile_id();
+        return $iProfileId ? $iProfileId : BxDolSession::getInstance()->getId();
+    }
+
+    protected function _getCacheKey(string $sType, string $sId): string
+    {
+        return 'sys_ai_search_' . $sType . '_' . md5($sId) . '_' . bx_site_hash() . '.php';
+    }
+}
+
+/** @} */

+ 22 - 7
inc/classes/BxDolDb.php

@@ -891,24 +891,39 @@ class BxDolDb extends BxDolFactory implements iBxDolSingleton
         return 'db_' . $sName . '_' . bx_site_hash() . '.php';
     }
 
-    public function getCache ($sName, $sFunc)
+    /**
+     * @param mixed $iTTL seconds, when an integer. A non-integer third argument is ignored:
+     *        existing callers pass a query string in that position. The file engine only
+     *        drops the entry when this read passes a TTL; other engines expire on write.
+     */
+    public function getCache ($sName, $sFunc, $iTTL = false)
     {
         if (!$this->getParam('sys_db_cache_enable'))
             return false;
-        
+
+        if (!is_int($iTTL) || $iTTL < 1)
+            $iTTL = false;
+
         $oCache = $this->getDbCacheObject ();
         $sKey = $this->genDbCacheKey($sName);
-        return $oCache->getData($sKey);
+        return $oCache->getData($sKey, $iTTL);
     }
-    
-    public function setCache ($sName, $mData)
+
+    /**
+     * @param mixed $iTTL seconds, when an integer greater than zero. Otherwise the engine default
+     *        (the file engine keeps the entry until it is deleted).
+     */
+    public function setCache ($sName, $mData, $iTTL = false)
     {
         if (!$this->getParam('sys_db_cache_enable'))
             return false;
-        
+
+        if (!is_int($iTTL) || $iTTL < 1)
+            $iTTL = false;
+
         $oCache = $this->getDbCacheObject ();
         $sKey = $this->genDbCacheKey($sName);
-        $oCache->setData($sKey, $mData);
+        $oCache->setData($sKey, $mData, $iTTL);
     }
     
     public function fromCache ($sName, $sFunc)

+ 20 - 10
install/sql/system.sql

@@ -1755,6 +1755,7 @@ CREATE TABLE `sys_modules` (
   `subtypes` int(11) unsigned NOT NULL default '0',
   `name` varchar(32) NOT NULL default '',
   `title` varchar(255) NOT NULL default '',
+  `note` varchar(255) NOT NULL default '',
   `vendor` varchar(64) NOT NULL default '',
   `version` varchar(32) NOT NULL default '',
   `help_url` varchar(128) NOT NULL default '',
@@ -6273,19 +6274,20 @@ INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title`, `designb
 -- service blocks
 SET @iBlockOrder = IFNULL((SELECT `order` FROM `sys_pages_blocks` WHERE `object` = '' AND `cell_id` = 0 ORDER BY `order` DESC LIMIT 1), 0);
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
-('', 0, 'system', '_sys_page_block_title_sys_create_post', '_sys_page_block_title_create_post', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;i:0;}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 1),
-('', 0, 'system', '_sys_page_block_title_sys_create_post_context', '_sys_page_block_title_create_post_context', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;s:12:"{profile_id}";}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 2),
-('', 0, 'system', '_sys_page_block_title_sys_create_post_public', '_sys_page_block_title_create_post_public', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:0:{}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 3),
+('', 0, 'system', '_sys_page_block_title_sys_search_ai', '_sys_page_block_title_search_ai', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:19:"get_block_search_ai";s:6:"params";a:0:{}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 1),
+('', 0, 'system', '_sys_page_block_title_sys_create_post', '_sys_page_block_title_create_post', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;i:0;}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 2),
+('', 0, 'system', '_sys_page_block_title_sys_create_post_context', '_sys_page_block_title_create_post_context', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;s:12:"{profile_id}";}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 3),
+('', 0, 'system', '_sys_page_block_title_sys_create_post_public', '_sys_page_block_title_create_post_public', 11, 1, 4, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:0:{}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 4),
 
-('', 0, 'system', '_sys_page_block_title_sys_std_site_submenu', '_sys_page_block_title_std_site_submenu', 3, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_site_submenu";s:6:"params";a:0:{}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 4),
+('', 0, 'system', '_sys_page_block_title_sys_std_site_submenu', '_sys_page_block_title_std_site_submenu', 3, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_site_submenu";s:6:"params";a:0:{}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 5),
 
-('', 0, 'system', '_sys_page_block_title_sys_author', '_sys_page_block_title_author', 3, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_block_author";s:6:"params";a:2:{i:0;s:8:"{module}";i:1;s:4:"{id}";}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 5),
+('', 0, 'system', '_sys_page_block_title_sys_author', '_sys_page_block_title_author', 3, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_block_author";s:6:"params";a:2:{i:0;s:8:"{module}";i:1;s:4:"{id}";}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 6),
 
-('', 0, 'system', '_sys_page_block_title_sys_recom_friends', '_sys_page_block_title_recom_friends', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:30:"browse_recommendations_friends";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 6),
-('', 0, 'system', '_sys_page_block_title_sys_recom_subscriptions', '_sys_page_block_title_recom_subscriptions', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:36:"browse_recommendations_subscriptions";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 7),
-('', 0, 'system', '_sys_page_block_title_sys_invitations', '_sys_page_block_title_invitations', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"browse_invitations";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 8),
+('', 0, 'system', '_sys_page_block_title_sys_recom_friends', '_sys_page_block_title_recom_friends', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:30:"browse_recommendations_friends";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 7),
+('', 0, 'system', '_sys_page_block_title_sys_recom_subscriptions', '_sys_page_block_title_recom_subscriptions', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:36:"browse_recommendations_subscriptions";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 8),
+('', 0, 'system', '_sys_page_block_title_sys_invitations', '_sys_page_block_title_invitations', 11, 1, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"browse_invitations";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, @iBlockOrder + 9),
 
-('', 0, 'system', '_sys_page_block_title_sys_ai_agent', '_sys_page_block_title_ai_agent', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"get_block_ai_agent";s:6:"params";a:1:{i:0;i:1;}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 9);
+('', 0, 'system', '_sys_page_block_title_sys_ai_agent', '_sys_page_block_title_ai_agent', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"get_block_ai_agent";s:6:"params";a:1:{i:0;i:1;}s:5:"class";s:13:"TemplServices";}', 0, 1, 1, @iBlockOrder + 10);
 
 -- content blocks
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`) VALUES
@@ -6721,7 +6723,7 @@ CREATE TABLE `sys_agents_models` (
   `key` varchar(255) NOT NULL DEFAULT '',
   `params` text NOT NULL,
   `params_user` text DEFAULT NULL,
-  `capabilities` enum('chatllm','chatvlm','embeddings') NOT NULL DEFAULT 'chatllm',
+  `capabilities` enum('chatllm','chatvlm','embeddings','judge') NOT NULL DEFAULT 'chatllm',
   `duplicate` tinyint(4) NOT NULL DEFAULT 1,
   `active` tinyint(4) NOT NULL DEFAULT 1,
   `changed` int(11) UNSIGNED NOT NULL DEFAULT 0,
@@ -6816,6 +6818,14 @@ INSERT INTO `sys_agents_models` (`type`, `model`, `title`, `icon`, `docs`, `key`
 INSERT INTO `sys_agents_models` (`type`, `model`, `title`, `icon`, `docs`, `key`, `params`, `params_user`, `capabilities`, `duplicate`, `active`, `changed`) VALUES
 ('cohere', 'command-a-03-2025', 'Cohere', 'ai-cohere.svg', 'https://docs.cohere.com/docs/models', '', '', NULL, 'chatllm', 0, 0, 0);
 
+SET @j = JSON_OBJECT(
+    'baseUri', 'https://api.typesafe.ai/v1',
+    'timeout', 15,
+    'retries', 2
+);
+INSERT INTO `sys_agents_models` (`type`, `model`, `title`, `icon`, `docs`, `key`, `params`, `params_user`, `capabilities`, `duplicate`, `active`, `changed`) VALUES
+('typesafe', 'jev-latest', 'TypeSafe (Jev)', 'ai-typesafe.svg', 'Judge model: answers typed questions (yes/no, choice, score) about a text with probabilities and confidence instead of generating text. It can not be assigned to an agent, it is used from code - moderation, guardrails, routing, search parsing. Models - https://docs.typesafe.ai/models, API - https://docs.typesafe.ai/api', '', CAST(@j AS CHAR), NULL, 'judge', 0, 0, 0);
+
 SET @j = JSON_OBJECT(
     'baseUri', 'https://api.moonshot.ai/v1',
     'parameters', JSON_OBJECT(

+ 1 - 1
modules/boonex/accounts/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_accounts',
     'title' => 'Accounts Manager',
-    'note' => 'Accounts Manager module.',
+    'note' => 'Accounts, logins, user accounts',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/acl/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_acl',
     'title' => 'Paid Levels',
-    'note' => 'Paid Levels module.',
+    'note' => 'Paid levels, membership levels, access',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/ads/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_ads',
     'title' => 'Ads',
-    'note' => 'Ads module.',
+    'note' => 'Ads, advertisements, classifieds',
     'version' => '15.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/albums/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_albums',
     'title' => 'Albums',
-    'note' => 'Photos & videos',
+    'note' => 'Albums, media collections',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 77 - 77
modules/boonex/analytics/install/config.php

@@ -1,77 +1,77 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    Analytics Analytics
- * @ingroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_analytics',
-    'title' => 'Analytics',
-    'note' => 'Analytics module.',
-    'version' => '15.0.2.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/analytics/',
-    'home_uri' => 'analytics',
-
-    'db_prefix' => 'bx_analytics_',
-    'class_prefix' => 'BxAnalytics',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'Analytics',
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_analytics',
+    'title' => 'Analytics',
+    'note' => 'Analytics, statistics, site metrics',
+    'version' => '15.0.2.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/analytics/',
+    'home_uri' => 'analytics',
+
+    'db_prefix' => 'bx_analytics_',
+    'class_prefix' => 'BxAnalytics',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Analytics',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 1 - 1
modules/boonex/anon_follow/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_anon_follow',
     'title' => 'Anonymous Follow',
-    'note' => 'This module add ability to follow anonymousy',
+    'note' => 'Anonymous follow, follow without signing in',
     'version' => '9.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.boonex.com/?section={module_name}',

+ 1 - 1
modules/boonex/antispam/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_antispam',
     'title' => 'Antispam',
-    'note' => 'Antispam tools',
+    'note' => 'Antispam, spam filter, blocked content',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/api/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_api',
     'title' => 'API',
-    'note' => 'API for backend.',
+    'note' => 'API, backend interface',
     'version' => '13.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/artificer/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_TEMPLATE,
     'name' => 'bx_artificer',
     'title' => 'Artificer',
-    'note' => 'Design template',
+    'note' => 'Artificer, design template, site theme',
     'version' => '15.0.7.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/attendant/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_attendant',
     'title' => 'Attendant',
-    'note' => 'Attendant module.',
+    'note' => 'Attendant, site assistant',
     'version' => '14.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/azure_b2c_con/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_azrb2c',
     'title' => 'Azure B2C Connect',
-    'note' => 'Join the site using Azure B2C account.',
+    'note' => 'Azure B2C login, join with an Azure B2C account',
     'version' => '13.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/azure_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_azrcon',
     'title' => 'Azure AD Connect',
-    'note' => 'Join the site using Azure AD account.',
+    'note' => 'Azure AD login, join with an Azure AD account',
     'version' => '13.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/cas_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_cas',
     'title' => 'CAS connect',
-    'note' => 'Join the site using CAS Identity Provider.',
+    'note' => 'CAS login, join with a CAS identity provider',
     'version' => '12.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 153 - 153
modules/boonex/channels/install/config.php

@@ -1,153 +1,153 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defdroup    Channels Channels
- * @indroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_channels',
-    'title' => 'Channels',
-    'note' => 'Channels functionality.',
-    'version' => '15.0.2.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use channels in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/channels/',
-    'home_uri' => 'channels',
-
-    'db_prefix' => 'bx_cnl_',
-    'class_prefix' => 'BxCnl',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'Channels',
-
-    /**
-     * Connections.
-     */
-    'connections' => array(
-        'sys_profiles_friends' => array ('type' => 'profiles'),
-        'sys_profiles_subscriptions' => array ('type' => 'profiles'),
-    ),
-
-    /**
-     * Menu triggers.
-     */
-    'menu_triggers' => array(
-        'trigger_profile_view_submenu',
-        'trigger_group_view_submenu',
-        'trigger_group_snippet_meta',
-        'trigger_group_view_actions',
-    ),
-
-    /**
-     * Page triggers.
-     */
-    'page_triggers' => array (
-        'trigger_page_profile_view_entry',
-        'trigger_page_group_view_entry',
-    ),
-
-    /**
-     * Storage objects to automatically delete files from upon module uninstallation.
-     * Note. Don't add storage objects used in transcoder objects.
-     */
-    'storages' => array(
-        'bx_channels_pics'
-    ),
-
-    /**
-     * Transcoders.
-     */
-    'transcoders' => array(
-        'bx_channels_icon',
-        'bx_channels_thumb',
-        'bx_channels_avatar',
-        'bx_channels_avatar_big',
-        'bx_channels_picture',
-        'bx_channels_cover',
-        'bx_channels_cover_thumb',
-        'bx_channels_gallery'
-    ),
-
-    /**
-     * Extended Search Forms.
-     */
-    'esearches' => array(
-        'bx_channels',
-        'bx_channels_cmts',
-    ),
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'process_esearches' => 1,
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'process_connections' => 1,
-        'process_deleted_profiles' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'process_menu_triggers' => 1,
-        'process_page_triggers' => 1,
-        'process_esearches' => 1,
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'unregister_transcoders' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-
-    /**
-     * Connections Section
-     */
-    'relations' => array(
-        'bx_timeline',
-        'bx_notifications',
-    ),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defdroup    Channels Channels
+ * @indroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_channels',
+    'title' => 'Channels',
+    'note' => 'Channels, hashtags, topics',
+    'version' => '15.0.2.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use channels in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/channels/',
+    'home_uri' => 'channels',
+
+    'db_prefix' => 'bx_cnl_',
+    'class_prefix' => 'BxCnl',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Channels',
+
+    /**
+     * Connections.
+     */
+    'connections' => array(
+        'sys_profiles_friends' => array ('type' => 'profiles'),
+        'sys_profiles_subscriptions' => array ('type' => 'profiles'),
+    ),
+
+    /**
+     * Menu triggers.
+     */
+    'menu_triggers' => array(
+        'trigger_profile_view_submenu',
+        'trigger_group_view_submenu',
+        'trigger_group_snippet_meta',
+        'trigger_group_view_actions',
+    ),
+
+    /**
+     * Page triggers.
+     */
+    'page_triggers' => array (
+        'trigger_page_profile_view_entry',
+        'trigger_page_group_view_entry',
+    ),
+
+    /**
+     * Storage objects to automatically delete files from upon module uninstallation.
+     * Note. Don't add storage objects used in transcoder objects.
+     */
+    'storages' => array(
+        'bx_channels_pics'
+    ),
+
+    /**
+     * Transcoders.
+     */
+    'transcoders' => array(
+        'bx_channels_icon',
+        'bx_channels_thumb',
+        'bx_channels_avatar',
+        'bx_channels_avatar_big',
+        'bx_channels_picture',
+        'bx_channels_cover',
+        'bx_channels_cover_thumb',
+        'bx_channels_gallery'
+    ),
+
+    /**
+     * Extended Search Forms.
+     */
+    'esearches' => array(
+        'bx_channels',
+        'bx_channels_cmts',
+    ),
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'process_esearches' => 1,
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'process_connections' => 1,
+        'process_deleted_profiles' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'process_menu_triggers' => 1,
+        'process_page_triggers' => 1,
+        'process_esearches' => 1,
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'unregister_transcoders' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+
+    /**
+     * Connections Section
+     */
+    'relations' => array(
+        'bx_timeline',
+        'bx_notifications',
+    ),
+);
+
+/** @} */

+ 77 - 77
modules/boonex/charts/install/config.php

@@ -1,77 +1,77 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    Charts Charts
- * @ingroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_charts',
-    'title' => 'Charts',
-    'note' => 'Charts module.',
-    'version' => '15.0.1.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/charts/',
-    'home_uri' => 'charts',
-
-    'db_prefix' => 'bx_charts_',
-    'class_prefix' => 'BxCharts',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'Charts',
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Charts Charts
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_charts',
+    'title' => 'Charts',
+    'note' => 'Charts, graphs, diagrams',
+    'version' => '15.0.1.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/charts/',
+    'home_uri' => 'charts',
+
+    'db_prefix' => 'bx_charts_',
+    'class_prefix' => 'BxCharts',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Charts',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 1 - 1
modules/boonex/chat_plus/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_chat_plus',
     'title' => 'RocketChat',
-    'note' => 'RocketChat integration.',
+    'note' => 'RocketChat, live chat',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/cidaas_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_cidaascon',
     'title' => 'Cidaas Connect',
-    'note' => 'Join the site using Cidaas account.',
+    'note' => 'Cidaas login, join with a Cidaas account',
     'version' => '15.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/classes/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_classes',
     'title' => 'Classes',
-    'note' => 'Classes module.',
+    'note' => 'Classes, schedules',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/contact/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_contact',
     'title' => 'Contact',
-    'note' => 'Contact module.',
+    'note' => 'Contact form, messages to the site',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/convos/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_convos',
     'title' => 'Conversations',
-    'note' => 'Conversations module.',
+    'note' => 'Conversations, private messages, inbox',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/courses/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_courses',
     'title' => 'Courses',
-    'note' => 'Basic course profiles functionality.',
+    'note' => 'Courses, lessons, learning',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/credits/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_credits',
     'title' => 'Credits',
-    'note' => 'Credits module.',
+    'note' => 'Credits, virtual currency, balance',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/datafox/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_datafox',
     'title' => 'Data Fox',
-    'note' => 'Data Fox API integration.',
+    'note' => 'Data Fox, API integration',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/decorous/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_TEMPLATE,
     'name' => 'bx_decorous',
     'title' => 'Decorous (discontinued)',
-    'note' => 'Design template',
+    'note' => 'Decorous, design template, site theme',
     'version' => '13.0.10.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/developer/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_developer',
     'title' => 'Developer',
-    'note' => 'Developer tools...',
+    'note' => 'Developer tools, debugging',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/dolphin_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_dolcon',
     'title' => 'Dolphin connect',
-    'note' => 'Join the site using Dolphin site account credentials.',
+    'note' => 'Dolphin login, join with a Dolphin account',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/dolphin_migration/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_dolphin_migration',
     'title' => 'Dolphin Migration',
-    'note' => 'Migration Tool',
+    'note' => 'Dolphin migration, import from Dolphin',
     'version' => '9.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/donations/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_donations',
     'title' => 'Donations',
-    'note' => 'Donations custom module.',
+    'note' => 'Donations, contributions, fundraising',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/drupal_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_drupal',
     'title' => 'Drupal connect',
-    'note' => 'Join the site using Drupal account.',
+    'note' => 'Drupal login, join with a Drupal account',
     'version' => '9.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/editor/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_editor',
     'title' => 'Editor',
-    'note' => 'Editor integration.',
+    'note' => 'Editor, rich text editor',
     'version' => '14.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/elasticsearch/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_elasticsearch',
     'title' => 'ElasticSearch',
-    'note' => 'Allows to translate content of the site on different languages',
+    'note' => 'Elasticsearch, search index, translate site content into different languages',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 2 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -3901,6 +3901,8 @@
     <string name="_sys_page_block_title_forgot_password"><![CDATA[Forgot Password]]></string>
     <string name="_sys_page_block_title_search_keyword_form"><![CDATA[Search Form]]></string>
     <string name="_sys_page_block_title_search_keyword_result"><![CDATA[Search Result]]></string>
+    <string name="_sys_page_block_title_sys_search_ai"><![CDATA[AI Search]]></string>
+    <string name="_sys_page_block_title_search_ai"><![CDATA[Search]]></string>
     <string name="_sys_page_block_title_membership_stats"><![CDATA[Membership Limits]]></string>
     <string name="_sys_page_block_system_title_login"><![CDATA[Log in with Sign up link]]></string>
     <string name="_sys_page_block_system_title_login_step2"><![CDATA[Log in - step2]]></string>

+ 1 - 1
modules/boonex/english/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_LANGUAGE,
     'name' => 'bx_en',
     'title' => 'English',
-    'note' => 'Language file',
+    'note' => 'English language, translations',
     'version' => '15.0.7.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/events/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_events',
     'title' => 'Events',
-    'note' => 'Events functionality.',
+    'note' => 'Events, meetups, happenings, things scheduled on a date',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/facebook_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_facebook',
     'title' => 'Facebook connect',
-    'note' => 'Join the site using Facebook account.',
+    'note' => 'Facebook login, join with a Facebook account',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/feedback/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_feedback',
     'title' => 'Feedback',
-    'note' => 'Feedback module.',
+    'note' => 'Feedback, suggestions, reports',
     'version' => '11.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/files/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_files',
     'title' => 'Files',
-    'note' => 'Basic blogging module.',
+    'note' => 'Files, documents',
     'version' => '15.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/fontawesome/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_fontawesome',
     'title' => 'Font Awesome Pro',
-    'note' => 'Font Awesome Pro integration.',
+    'note' => 'Font Awesome, icons',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/forum/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_forum',
     'title' => 'Discussions',
-    'note' => 'Discussions module.',
+    'note' => 'Forum discussions, threads, questions',
     'version' => '15.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/froala/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_froala',
     'title' => 'Froala (not supported)',
-    'note' => 'Froala editor integration.',
+    'note' => 'Froala, rich text editor',
     'version' => '13.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/github/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_github',
     'title' => 'GitHub',
-    'note' => 'GitHub integration module.',
+    'note' => 'GitHub integration',
     'version' => '15.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 136 - 136
modules/boonex/glossary/install/config.php

@@ -1,136 +1,136 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    Glossary Glossary
- * @ingroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-      * Main Section.
-      */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_glossary',
-    'title' => 'Glossary',
-    'note' => 'Glossary module.',
-    'version' => '15.0.3.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-      * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-      */
-    'home_dir' => 'boonex/glossary/',
-    'home_uri' => 'glossary',
-
-    'db_prefix' => 'bx_glossary_',
-    'class_prefix' => 'BxGlsr',
-
-    /**
-      * Category for language keys.
-      */
-    'language_category' => 'Glossary',
-
-    /**
-      * List of page triggers.
-      */
-    'page_triggers' => array (
-        'trigger_page_profile_view_entry',
-    ),
-    
-    /**
-      * Menu triggers.
-      */
-    'menu_triggers' => array(
-        'trigger_profile_view_submenu',
-        'trigger_group_view_submenu'
-    ),
-
-    /**
-      * Storage objects to automatically delete files from upon module uninstallation.
-      * Note. Don't add storage objects used in transcoder objects.
-      */
-    'storages' => array(
-        'bx_glossary_files'
-    ),
-
-    /**
-      * Transcoders.
-      */
-    'transcoders' => array(
-        'bx_glossary_preview',
-        'bx_glossary_gallery',
-        'bx_glossary_cover'
-    ),
-
-    /**
-      * Extended Search Forms.
-      */
-    'esearches' => array(
-        'bx_glossary',
-        'bx_glossary_cmts'
-    ),
-
-    /**
-      * Installation/Uninstallation Section.
-      */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'process_esearches' => 1,
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'process_menu_triggers' => 1,
-        'process_page_triggers' => 1,
-        'process_esearches' => 1,
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'update_relations' => 1,
-        'unregister_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-      * Dependencies Section
-      */
-    'dependencies' => array(),
-
-    /**
-      * Relations Section
-      */
-    'relations' => array(
-        'bx_timeline',
-        'bx_notifications'
-    ),
-
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Glossary Glossary
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+      * Main Section.
+      */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_glossary',
+    'title' => 'Glossary',
+    'note' => 'Glossary, terms, definitions',
+    'version' => '15.0.3.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+      * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+      */
+    'home_dir' => 'boonex/glossary/',
+    'home_uri' => 'glossary',
+
+    'db_prefix' => 'bx_glossary_',
+    'class_prefix' => 'BxGlsr',
+
+    /**
+      * Category for language keys.
+      */
+    'language_category' => 'Glossary',
+
+    /**
+      * List of page triggers.
+      */
+    'page_triggers' => array (
+        'trigger_page_profile_view_entry',
+    ),
+    
+    /**
+      * Menu triggers.
+      */
+    'menu_triggers' => array(
+        'trigger_profile_view_submenu',
+        'trigger_group_view_submenu'
+    ),
+
+    /**
+      * Storage objects to automatically delete files from upon module uninstallation.
+      * Note. Don't add storage objects used in transcoder objects.
+      */
+    'storages' => array(
+        'bx_glossary_files'
+    ),
+
+    /**
+      * Transcoders.
+      */
+    'transcoders' => array(
+        'bx_glossary_preview',
+        'bx_glossary_gallery',
+        'bx_glossary_cover'
+    ),
+
+    /**
+      * Extended Search Forms.
+      */
+    'esearches' => array(
+        'bx_glossary',
+        'bx_glossary_cmts'
+    ),
+
+    /**
+      * Installation/Uninstallation Section.
+      */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'process_esearches' => 1,
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'process_menu_triggers' => 1,
+        'process_page_triggers' => 1,
+        'process_esearches' => 1,
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'unregister_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+      * Dependencies Section
+      */
+    'dependencies' => array(),
+
+    /**
+      * Relations Section
+      */
+    'relations' => array(
+        'bx_timeline',
+        'bx_notifications'
+    ),
+
+);
+
+/** @} */

+ 1 - 1
modules/boonex/google_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_googlecon',
     'title' => 'Google connect',
-    'note' => 'Join the site using LinkedIn account.',
+    'note' => 'Google login, join with a Google account',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/google_tagmanager/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_googletagman',
     'title' => 'Google Tag Manager',
-    'note' => 'Google Tag Manager replaces hard-coded tags that are used for marketing, analytics, and testing on a website, with dynamic tags that are easier to implement and update.',
+    'note' => 'Google Tag Manager, marketing tags, analytics tags',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/groups/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_groups',
     'title' => 'Groups',
-    'note' => 'Basic group profiles functionality.',
+    'note' => 'Groups, communities',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/help_tours/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_help_tours',
     'title' => 'Help Tours',
-    'note' => 'Help Tours module',
+    'note' => 'Help tours, guided tours, onboarding',
     'version' => '14.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/intercom/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_intercom',
     'title' => 'Intercom',
-    'note' => 'Intercom integration.',
+    'note' => 'Intercom, customer chat, support',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/invites/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_invites',
     'title' => 'Invitations',
-    'note' => 'Invitations module.',
+    'note' => 'Invitations, invite codes, referrals',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/jobs/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_jobs',
     'title' => 'Jobs',
-    'note' => 'Basic jobs functionality.',
+    'note' => 'Jobs, vacancies',
     'version' => '14.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/linkedin_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_linkedin',
     'title' => 'LinkedIn connect',
-    'note' => 'Join the site using LinkedIn account.',
+    'note' => 'LinkedIn login, join with a LinkedIn account',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/lucid/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_TEMPLATE,
     'name' => 'bx_lucid',
     'title' => 'Lucid',
-    'note' => 'Design template',
+    'note' => 'Lucid, design template, site theme',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/mailchimp/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_mailchimp',
     'title' => 'Mailchimp',
-    'note' => 'Mailchimp integration.',
+    'note' => 'Mailchimp, email lists, newsletters',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 77 - 77
modules/boonex/mapshow/install/config.php

@@ -1,77 +1,77 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    MapShow Display last sign up users on map
- * @ingroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_mapshow',
-    'title' => 'Map Show',
-    'note' => 'Map Show module.',
-    'version' => '1.0.0.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '9.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/mapshow/',
-    'home_uri' => 'mapshow',
-
-    'db_prefix' => 'bx_mapshow_',
-    'class_prefix' => 'BxMapShow',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'MapShow',
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    MapShow Display last sign up users on map
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_mapshow',
+    'title' => 'Map Show',
+    'note' => 'Maps, locations shown on a map',
+    'version' => '1.0.0.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '9.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/mapshow/',
+    'home_uri' => 'mapshow',
+
+    'db_prefix' => 'bx_mapshow_',
+    'class_prefix' => 'BxMapShow',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'MapShow',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 1 - 1
modules/boonex/markerio/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_markerio',
     'title' => 'Marker.io',
-    'note' => 'Marker.io integration module.',
+    'note' => 'Marker.io, visual feedback, bug reports',
     'version' => '13.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/market/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_market',
     'title' => 'Market',
-    'note' => 'Market module.',
+    'note' => 'Products, market listings, things for sale',
     'version' => '15.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/massmailer/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_massmailer',
     'title' => 'Mass mailer',
-    'note' => 'Mass mailer module.',
+    'note' => 'Mass mailer, newsletters, bulk email',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/new_comments/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_new_comments',
     'title' => 'New Comments',
-    'note' => 'This module add ability to mark new commentaries',
+    'note' => 'New comments, unread comments',
     'version' => '9.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.boonex.com/?section={module_name}',

+ 1 - 1
modules/boonex/nexus/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_nexus',
     'title' => 'Nexus',
-    'note' => 'Nexus - Mobile Apps and Desktop apps connector.',
+    'note' => 'Nexus, mobile apps, desktop apps',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/notifications/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_notifications',
     'title' => 'Notifications',
-    'note' => 'Notifications module.',
+    'note' => 'Notifications, alerts',
     'version' => '15.0.6.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/oauth2/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_oauth',
     'title' => 'OAuth2 Server',
-    'note' => 'Allow to use site user credentials and basic API',
+    'note' => 'OAuth2, site credentials, API access',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/ocean/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_TEMPLATE,
     'name' => 'bx_ocean',
     'title' => 'Ocean (discontinued)',
-    'note' => 'Design template',
+    'note' => 'Ocean, design template, site theme',
     'version' => '13.0.10.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/okta_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_oktacon',
     'title' => 'Okta Connect',
-    'note' => 'Join the site using Okta OAuth',
+    'note' => 'Okta login, join with an Okta account',
     'version' => '13.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/opencv/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_opencv',
     'title' => 'OpenCV',
-    'note' => 'OpenCV integration.',
+    'note' => 'OpenCV, image recognition',
     'version' => '13.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/organizations/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_organizations',
     'title' => 'Organizations',
-    'note' => 'Basic organization profiles functionality.',
+    'note' => 'Organizations, companies',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/payment/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_payment',
     'title' => 'Payment',
-    'note' => 'Payment module.',
+    'note' => 'Payments, checkout, purchases',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/persons/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_persons',
     'title' => 'Persons',
-    'note' => 'Basic person profiles functionality.',
+    'note' => 'People, members, users, profiles',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/photos/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_photos',
     'title' => 'Photos',
-    'note' => 'Basic photo posting module.',
+    'note' => 'Photos, pictures',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/plyr/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_plyr',
     'title' => 'Plyr',
-    'note' => 'Plyr player integration.',
+    'note' => 'Plyr, video player, audio player',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/polls/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_polls',
     'title' => 'Polls',
-    'note' => 'Basic poll module.',
+    'note' => 'Polls, votes',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/posts/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_posts',
     'title' => 'Posts',
-    'note' => 'Basic blogging module.',
+    'note' => 'Posts, articles, blog entries, news',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/profiler/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_profiler',
     'title' => 'Profiler',
-    'note' => 'Measure different site timings.',
+    'note' => 'Profiler, timings, performance',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/protean/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_TEMPLATE,
     'name' => 'bx_protean',
     'title' => 'Protean',
-    'note' => 'Design template',
+    'note' => 'Protean, design template, site theme',
     'version' => '15.0.5.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 77 - 77
modules/boonex/quoteofday/install/config.php

@@ -1,77 +1,77 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    QuoteOfTheDay Quote of the Day
- * @ingroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_quoteofday',
-    'title' => 'Quote of the Day',
-    'note' => 'Quote of the Day module.',
-    'version' => '15.0.1.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/quoteofday/',
-    'home_uri' => 'quoteofday',
-
-    'db_prefix' => 'bx_quoteofday_',
-    'class_prefix' => 'BxQuoteOfDay',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'Quote Of Day',
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    QuoteOfTheDay Quote of the Day
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_quoteofday',
+    'title' => 'Quote of the Day',
+    'note' => 'Quote of the day, daily quotes',
+    'version' => '15.0.1.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/quoteofday/',
+    'home_uri' => 'quoteofday',
+
+    'db_prefix' => 'bx_quoteofday_',
+    'class_prefix' => 'BxQuoteOfDay',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Quote Of Day',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 1 - 1
modules/boonex/reminders/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_reminders',
     'title' => 'Reminders',
-    'note' => 'Reminders module.',
+    'note' => 'Reminders, scheduled reminders',
     'version' => '11.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/reputation/install/config.php

@@ -16,7 +16,7 @@ $aConfig = [
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_reputation',
     'title' => 'Reputation',
-    'note' => 'Reputation module.',
+    'note' => 'Reputation, points, ranks',
     'version' => '15.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/resources/install/config.php

@@ -16,7 +16,7 @@ $aConfig = [
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_resources',
     'title' => 'Resources',
-    'note' => 'Resources module.',
+    'note' => 'Resources, shared resources',
     'version' => '15.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/reviews/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_reviews',
     'title' => 'Reviews',
-    'note' => 'Reviews based on Posts module.',
+    'note' => 'Reviews, ratings, opinions',
     'version' => '14.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 2 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -3857,6 +3857,8 @@
 	<string name="_sys_page_block_title_forgot_password"><![CDATA[Забыли пароль]]></string>
         <string name="_sys_page_block_title_search_keyword_form"><![CDATA[Форма поиска]]></string>
         <string name="_sys_page_block_title_search_keyword_result"><![CDATA[Результаты поиска]]></string>
+        <string name="_sys_page_block_title_sys_search_ai"><![CDATA[AI поиск]]></string>
+        <string name="_sys_page_block_title_search_ai"><![CDATA[Поиск]]></string>
         <string name="_sys_page_block_title_membership_stats"><![CDATA[Лимиты членства]]></string>
 	<string name="_sys_page_block_system_title_login"><![CDATA[Вход с линкой регистрации]]></string>
 	<string name="_sys_page_block_system_title_login_step2"><![CDATA[Вход - шаг 2]]></string>

+ 1 - 1
modules/boonex/russian/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_LANGUAGE,
     'name' => 'bx_ru',
     'title' => 'Russian',
-    'note' => 'Language file',
+    'note' => 'Russian language, translations',
     'version' => '15.0.7.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/se_migration/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_se_migration',
     'title' => 'SocialEngine Migration',
-    'note' => 'Migration Tool',
+    'note' => 'SocialEngine migration, import from SocialEngine',
     'version' => '9.0.0',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/shopify/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_shopify',
     'title' => 'Shopify',
-    'note' => 'Shopify shopping module.',
+    'note' => 'Shopify, shop, products for sale',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/smtpmailer/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_smtp',
     'title' => 'SMTP Mailer',
-    'note' => 'Use remote SMTP server for sending mails.',
+    'note' => 'SMTP mailer, outgoing email',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/snipcart/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_snipcart',
     'title' => 'Snipcart',
-    'note' => 'Snipcart shopping module.',
+    'note' => 'Snipcart, shopping cart, products for sale',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 152 - 152
modules/boonex/spaces/install/config.php

@@ -1,152 +1,152 @@
-<?php
-/**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defdroup    Spaces Spaces
- * @indroup     UnaModules
- *
- * @{
- */
-
-$aConfig = array(
-
-    /**
-     * Main Section.
-     */
-    'type' => BX_DOL_MODULE_TYPE_MODULE,
-    'name' => 'bx_spaces',
-    'title' => 'Spaces',
-    'note' => 'Spaces functionality.',
-    'version' => '15.0.4.DEV',
-    'vendor' => 'UNA INC',
-    'help_url' => 'http://feed.una.io/?section={module_name}',
-
-    'compatible_with' => array(
-        '15.0.x'
-    ),
-
-    /**
-     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
-     */
-    'home_dir' => 'boonex/spaces/',
-    'home_uri' => 'spaces',
-
-    'db_prefix' => 'bx_spaces_',
-    'class_prefix' => 'BxSpaces',
-
-    /**
-     * Category for language keys.
-     */
-    'language_category' => 'Spaces',
-
-    /**
-     * Connections.
-     */
-    'connections' => array(
-        'sys_profiles_friends' => array ('type' => 'profiles'),
-        'sys_profiles_subscriptions' => array ('type' => 'profiles'),
-    ),
-
-    /**
-     * Menu triggers.
-     */
-    'menu_triggers' => array(
-        'trigger_profile_view_submenu',
-        'trigger_group_view_submenu',
-        'trigger_group_snippet_meta',
-        'trigger_group_view_actions',
-    ),
-
-    /**
-     * Page triggers.
-     */
-    'page_triggers' => array (
-        'trigger_page_group_view_entry',
-    ),
-
-    /**
-     * Storage objects to automatically delete files from upon module uninstallation.
-     * Note. Don't add storage objects used in transcoder objects.
-     */
-    'storages' => array(
-        'bx_spaces_pics'
-    ),
-
-    /**
-     * Transcoders.
-     */
-    'transcoders' => array(
-        'bx_spaces_icon',
-        'bx_spaces_thumb',
-        'bx_spaces_avatar',
-        'bx_spaces_avatar_big',
-        'bx_spaces_picture',
-        'bx_spaces_cover',
-        'bx_spaces_cover_thumb',
-        'bx_spaces_gallery'
-    ),
-
-    /**
-     * Extended Search Forms.
-     */
-    'esearches' => array(
-        'bx_spaces',
-        'bx_spaces_cmts',
-    ),
-
-    /**
-     * Installation/Uninstallation Section.
-     */
-    'install' => array(
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'uninstall' => array (
-        'process_esearches' => 1,
-        'execute_sql' => 1,
-        'update_languages' => 1,
-        'process_connections' => 1,
-        'process_deleted_profiles' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable' => array(
-        'execute_sql' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'enable_success' => array(
-        'process_menu_triggers' => 1,
-        'process_page_triggers' => 1,
-        'process_esearches' => 1,
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable' => array (
-        'execute_sql' => 1,
-        'unregister_transcoders' => 1,
-        'update_relations' => 1,
-        'clear_db_cache' => 1,
-    ),
-    'disable_failed' => array (
-        'register_transcoders' => 1,
-        'clear_db_cache' => 1,
-    ),
-
-    /**
-     * Dependencies Section
-     */
-    'dependencies' => array(),
-
-    /**
-     * Connections Section
-     */
-    'relations' => array(
-        'bx_timeline',
-        'bx_notifications',
-    ),
-);
-
-/** @} */
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defdroup    Spaces Spaces
+ * @indroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_spaces',
+    'title' => 'Spaces',
+    'note' => 'Spaces, communities',
+    'version' => '15.0.4.DEV',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.x'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/spaces/',
+    'home_uri' => 'spaces',
+
+    'db_prefix' => 'bx_spaces_',
+    'class_prefix' => 'BxSpaces',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Spaces',
+
+    /**
+     * Connections.
+     */
+    'connections' => array(
+        'sys_profiles_friends' => array ('type' => 'profiles'),
+        'sys_profiles_subscriptions' => array ('type' => 'profiles'),
+    ),
+
+    /**
+     * Menu triggers.
+     */
+    'menu_triggers' => array(
+        'trigger_profile_view_submenu',
+        'trigger_group_view_submenu',
+        'trigger_group_snippet_meta',
+        'trigger_group_view_actions',
+    ),
+
+    /**
+     * Page triggers.
+     */
+    'page_triggers' => array (
+        'trigger_page_group_view_entry',
+    ),
+
+    /**
+     * Storage objects to automatically delete files from upon module uninstallation.
+     * Note. Don't add storage objects used in transcoder objects.
+     */
+    'storages' => array(
+        'bx_spaces_pics'
+    ),
+
+    /**
+     * Transcoders.
+     */
+    'transcoders' => array(
+        'bx_spaces_icon',
+        'bx_spaces_thumb',
+        'bx_spaces_avatar',
+        'bx_spaces_avatar_big',
+        'bx_spaces_picture',
+        'bx_spaces_cover',
+        'bx_spaces_cover_thumb',
+        'bx_spaces_gallery'
+    ),
+
+    /**
+     * Extended Search Forms.
+     */
+    'esearches' => array(
+        'bx_spaces',
+        'bx_spaces_cmts',
+    ),
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'process_esearches' => 1,
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'process_connections' => 1,
+        'process_deleted_profiles' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'process_menu_triggers' => 1,
+        'process_page_triggers' => 1,
+        'process_esearches' => 1,
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'unregister_transcoders' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+
+    /**
+     * Connections Section
+     */
+    'relations' => array(
+        'bx_timeline',
+        'bx_notifications',
+    ),
+);
+
+/** @} */

+ 1 - 1
modules/boonex/stories/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_stories',
     'title' => 'Stories',
-    'note' => 'Photo & video stories',
+    'note' => 'Stories, photo stories, video stories',
     'version' => '14.0.0.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/stream/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_stream',
     'title' => 'Stream',
-    'note' => 'Live streaming module.',
+    'note' => 'Live streams, broadcasts',
     'version' => '15.0.3.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/stripe_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_stripe_connect',
     'title' => 'Stripe Connect',
-    'note' => 'Organize eCommerce platform using Stripe Connect.',
+    'note' => 'Stripe Connect, payments, eCommerce',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/tasks/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_tasks',
     'title' => 'Tasks',
-    'note' => 'Tasks module.',
+    'note' => 'Tasks, to-dos',
     'version' => '15.0.7.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/timeline/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_timeline',
     'title' => 'Timeline',
-    'note' => 'Timeline module.',
+    'note' => 'Feed updates, timeline',
     'version' => '15.0.7.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/twitter_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_twitter',
     'title' => 'Twitter connect',
-    'note' => 'Join the site using Twitter account.',
+    'note' => 'Twitter login, join with a Twitter account',
     'version' => '15.0.1.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/una_connect/install/config.php

@@ -16,7 +16,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_unacon',
     'title' => 'UNA connect',
-    'note' => 'Join the site using another UNA site account credentials.',
+    'note' => 'UNA connect, join with another UNA site',
     'version' => '15.0.2.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

+ 1 - 1
modules/boonex/videos/install/config.php

@@ -17,7 +17,7 @@ $aConfig = array(
     'type' => BX_DOL_MODULE_TYPE_MODULE,
     'name' => 'bx_videos',
     'title' => 'Videos',
-    'note' => 'Basic video posting module.',
+    'note' => 'Videos',
     'version' => '15.0.4.DEV',
     'vendor' => 'UNA INC',
     'help_url' => 'http://feed.una.io/?section={module_name}',

Некоторые файлы не были показаны из-за большого количества измененных файлов